Fix specials wipe, leftover torrent dirs, and default-stack pins

Keep TV specials when a request covers only regular seasons. Encode
first-run passwords, default missing config-show fields, and strip
Windows CRLF on Python, Dockerfiles, and migrations. Pin express-stack
images. After Sonarr title delete, remove leftover torrents/ dirs even
when qB already dropped the torrent. Document that only the default
stack is supported.
This commit is contained in:
soulis-1256 2026-08-16 21:19:15 +03:00
parent 7d2d0d1770
commit edac50f8a2
No known key found for this signature in database
23 changed files with 494 additions and 159 deletions

View file

@ -11,21 +11,27 @@ Self-hosted media automation, set up in minutes, not a weekend.
## Features
- **Guided Multi-Platform Install:** Interactive wizard for Linux or Windows (via WSL2).
- **Automated Service Wiring:** Automatically configures and connects Radarr, Sonarr, Prowlarr, qBittorrent, Bazarr, Seerr, Recyclarr, and your media server post-install.
- **Automated Service Wiring:** Automatically configures and connects Radarr, Sonarr, Prowlarr, qBittorrent, Bazarr, Seerr, Recyclarr, and Jellyfin post-install.
- **VPN-First Architecture:** Gluetun integration forces download client traffic through the VPN, complete with start-time verification and a stalled-routing watchdog.
- **Built-in Operator CLI:** Manage the stack (`start`, `stop`, `status`), edit configurations, snapshot backups, and update containers from the command line.
- **Optimized Media Layout:** Built-in hardlinks-friendly structure for Jellyfin/Emby/Plex with out-of-the-box TRaSH Guides quality profiles.
- **Optimized Media Layout:** Built-in hardlinks-friendly structure for Jellyfin with out-of-the-box TRaSH Guides quality profiles.
## The Stack
- **Media Server:** Jellyfin (recommended), Emby, or Plex
## The Stack (supported)
Official support is the **express / default** install only:
- **Media Server:** Jellyfin
- **Download Client:** qBittorrent
- **Managers:** Sonarr, Radarr
- **Subtitles:** Bazarr (auto-wired to Sonarr/Radarr; optional OpenSubtitles.com)
- **Indexing:** Prowlarr
- **Request Management:** Seerr (delete request removes that request’s media via `seerr-gateway`; TV is season-scoped — see [docs/seerr-delete-request.md](docs/seerr-delete-request.md))
- **Quality Profiles:** Recyclarr
- **VPN (optional):** Gluetun + watchdog
- **Management:** Built-in `assemblrr` CLI (`status`, `logs`, `start`/`stop`, …)
Emby, Plex, Lidarr, SABnzbd, Portainer, Watchtower, and anything you add in `compose/custom.yaml` are **not officially supported**. They may exist as compose profiles or commented examples. Wiring, purge, upgrade, and bugfixes target the default stack above.
## Installation
### Linux / WSL2

View file

@ -70,8 +70,10 @@ elif [ -f "$INSTALL_DIR/.${APP_NAME:-assemblrr}-config" ]; then
safe_source "$INSTALL_DIR/.${APP_NAME:-assemblrr}-config"
fi
VPN_ENABLED="${VPN_ENABLED:-n}"
VPN_TYPE="${VPN_TYPE:-openvpn}"
MEDIA_SERVICE="${MEDIA_SERVICE:-jellyfin}"
MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-${HOME}/assemblrr-media}"
TZ="${TZ:-UTC}"
# Source branding from install directory
load_branding "$INSTALL_DIR" 2>/dev/null || true
@ -764,10 +766,10 @@ update_containers() {
show_config() {
echo "${APP_DISPLAY_NAME} Configuration:"
print_detected_locations
echo " Media service: $MEDIA_SERVICE"
echo " VPN enabled: $VPN_ENABLED"
echo " VPN type: $VPN_TYPE"
echo " Timezone: $TZ"
echo " Media service: ${MEDIA_SERVICE:-jellyfin}"
echo " VPN enabled: ${VPN_ENABLED:-n}"
echo " VPN type: ${VPN_TYPE:-openvpn}"
echo " Timezone: ${TZ:-UTC}"
if [ "${VPN_ENABLED:-n}" = "y" ]; then
echo " VPN secrets: $INSTALL_DIR/secrets/"
fi

View file

@ -2,7 +2,7 @@ services:
# Jellyfin — open source media server (recommended)
jellyfin:
profiles: ["jellyfin"]
image: lscr.io/linuxserver/jellyfin
image: lscr.io/linuxserver/jellyfin:10.11.11
container_name: jellyfin
environment:
- PUID=${PUID}
@ -26,7 +26,7 @@ services:
assemblrr_network:
ipv4_address: 172.60.0.11
# Emby — media server with premium features
# Emby — UNSUPPORTED optional profile (express/default is Jellyfin only).
emby:
profiles: ["emby"]
image: lscr.io/linuxserver/emby
@ -53,7 +53,7 @@ services:
assemblrr_network:
ipv4_address: 172.60.0.11
# Plex — popular media server with advanced features (uses host networking)
# Plex — UNSUPPORTED optional profile (express/default is Jellyfin only).
plex:
profiles: ["plex"]
image: lscr.io/linuxserver/plex
@ -78,7 +78,7 @@ services:
# qBittorrent — torrent client (ports added by overlay)
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
image: lscr.io/linuxserver/qbittorrent:5.2.3
container_name: qbittorrent
environment:
- PUID=${PUID}
@ -99,7 +99,7 @@ services:
# Sonarr — TV show management and automation
sonarr:
image: lscr.io/linuxserver/sonarr
image: lscr.io/linuxserver/sonarr:4.0.19.2979-ls321
container_name: sonarr
environment:
- PUID=${PUID}
@ -126,7 +126,7 @@ services:
# Radarr — movie management and automation
radarr:
image: lscr.io/linuxserver/radarr
image: lscr.io/linuxserver/radarr:6.3.0.10514-ls313
container_name: radarr
environment:
- PUID=${PUID}
@ -153,7 +153,7 @@ services:
# Prowlarr — indexer management for *arr apps
prowlarr:
image: lscr.io/linuxserver/prowlarr
image: lscr.io/linuxserver/prowlarr:2.5.2.5491-ls156
container_name: prowlarr
environment:
- PUID=${PUID}
@ -180,7 +180,7 @@ services:
# can cascade into media file purge. Seerr stays on the Docker network only.
# Retirement: see docs/seerr-delete-request.md
seerr:
image: ghcr.io/seerr-team/seerr:latest
image: ghcr.io/seerr-team/seerr:v3.4.1
container_name: seerr
init: true
environment:
@ -205,7 +205,7 @@ services:
# TV: season-scoped via Sonarr when the request is a subset of remaining seasons.
# SEERR_DELETE_REQUEST_PURGE=0 disables intercept (stock Seerr semantics).
seerr-gateway:
image: python:3.12-alpine
image: python:3.12.14-alpine
container_name: seerr-gateway
environment:
- TZ=${TZ}
@ -244,9 +244,9 @@ services:
ipv4_address: 172.60.0.24
# Recyclarr - automatically syncs TRaSH guides
# :latest is no longer published; :8 tracks the current major (see recyclarr.dev).
# :latest is no longer published; pin a 8.x release (see recyclarr.dev).
recyclarr:
image: ghcr.io/recyclarr/recyclarr:8
image: ghcr.io/recyclarr/recyclarr:8.7.1
container_name: recyclarr
user: ${PUID}:${PGID}
environment:
@ -291,7 +291,7 @@ services:
# Bazarr — automatic subtitle management (Sonarr/Radarr companion)
bazarr:
image: lscr.io/linuxserver/bazarr
image: lscr.io/linuxserver/bazarr:1.6.0
container_name: bazarr
environment:
- PUID=${PUID}

View file

@ -1,11 +1,9 @@
# Custom services overlay
# Rename this file to custom.yaml to enable optional services
# They will automatically join the assemblrr_network
# Custom services overlay — NOT officially supported.
# Rename this file to custom.yaml to add extra containers. They join assemblrr_network
# but are not wired, upgraded, or bugfixed by assemblrr. Express/default support is
# Jellyfin + qBittorrent + Sonarr/Radarr/Prowlarr/Bazarr/Seerr/Recyclarr (+ optional VPN).
#
# Usage: docker compose -f base.yaml -f [direct-access|vpn].yaml -f custom.yaml --profile <media_service> up -d
#
# These services are NOT auto-configured by assemblrr.
# You will need to configure them manually through their web UIs after enabling.
networks:
default:
@ -18,7 +16,7 @@ services:
# Requires: Prowlarr indexer, qBittorrent download client (configure manually)
# Web UI: http://<host>:8686
# lidarr:
# image: lscr.io/linuxserver/lidarr
# image: lscr.io/linuxserver/lidarr:3.1.0.4875-ls39
# container_name: lidarr
# environment:
# - PUID=${PUID}
@ -40,7 +38,7 @@ services:
# Note: Routes through VPN if enabled (same as qBittorrent)
# Web UI: http://<host>:8080
# sabnzbd:
# image: lscr.io/linuxserver/sabnzbd:latest
# image: lscr.io/linuxserver/sabnzbd:5.1.0-ls267
# container_name: sabnzbd
# environment:
# - PUID=${PUID}

View file

@ -3,7 +3,7 @@
services:
gluetun:
image: qmcgaw/gluetun:latest
image: qmcgaw/gluetun:v3.41.3
container_name: gluetun
cap_add:
- NET_ADMIN
@ -61,7 +61,7 @@ services:
restart: unless-stopped
deunhealth:
image: qmcgaw/deunhealth
image: qmcgaw/deunhealth:v0.3.0
container_name: deunhealth
network_mode: "none"
environment:

View file

@ -5,7 +5,8 @@ should also remove **that request’s** library files under `media/` and the
matching qBittorrent torrent/data under `torrents/` (hardlink-aware).
- **Movies:** the whole title.
- **TV:** only the seasons on that request. Other seasons of the same show stay.
- **TV:** only the seasons on that request. Other seasons of the same show stay,
including **specials** (season 0) unless that request listed them.
Stock Seerr does **not** do that. `DELETE /api/v1/request/:id` only removes the
request row. The path that frees an entire title is
@ -23,8 +24,8 @@ On **`DELETE /api/v1/request/:id`** only (when purge is enabled):
requested season numbers (same auth headers as the client).
2. Purge files:
- **Movie**, or a TV request that covers every remaining on-disk/monitored
season → `DELETE /api/v1/media/:mediaId/file?is4k=…` (Seerr → *arr
`deleteFiles` → assemblrr purge hooks / qB).
season (specials count as remaining) → `DELETE /api/v1/media/:mediaId/file?is4k=…`
(Seerr → *arr `deleteFiles` → assemblrr purge hooks / qB).
- **TV subset of seasons** → Sonarr `DELETE /episodefile/{id}` for those
seasons only, then unmonitor them. Seerr title-level file-delete is
**not** called, so `/data/media/tv/Show` is not wiped. `media-purge-watch`
@ -53,8 +54,10 @@ series** from Sonarr (`deleteFiles: true`). assemblrr does **not** intercept
that path: it means “this title”.
A recycle bin at `/data/media/.recycle` (Sonarr/Radarr, 7-day cleanup) makes a
mistaken title delete recoverable for library files. qB torrents may still be
removed by the series-delete hook.
mistaken title delete recoverable for library files. The series-delete hook
then removes the matching qB torrent **and** leftover folders under
`torrents/tv` / `torrents/movies` (Completed Download Handling often drops the
qB row first, which used to leave the download directory behind).
### Purge safety (qB)

View file

@ -67,29 +67,47 @@ qbit_vpn_enabled() {
# qB 5.x + WireGuard does not bind the POINTOPOINT tun by itself; "Any"
# listens on eth0 and Gluetun's kill switch then drops tracker/DHT packets
# ("Operation not permitted" → stalledDL).
# $1 optional extra JSON object fields, including a leading comma
# e.g. ',"web_ui_username":"x","web_ui_password":"y"'
# Optional $1/$2 = WebUI username/password (jq-escaped).
qbit_core_prefs_json() {
local extras="${1:-}"
local iface
local user="${1:-}"
local pass="${2:-}"
local iface=""
if qbit_vpn_enabled; then
iface=',"current_network_interface":"tun0"'
else
# Empty string clears a leftover tun0 bind after VPN is turned off.
iface=',"current_network_interface":""'
iface="tun0"
fi
printf '%s' "{\"save_path\":\"/data/torrents\",\"temp_path\":\"/data/torrents/incomplete\",\"temp_path_enabled\":true,\"auto_tmm_enabled\":true${iface}${extras}}"
if [ -n "$user" ]; then
jq -nc --arg iface "$iface" --arg user "$user" --arg pass "$pass" \
'{save_path:"/data/torrents",temp_path:"/data/torrents/incomplete",temp_path_enabled:true,auto_tmm_enabled:true,current_network_interface:$iface,web_ui_username:$user,web_ui_password:$pass}'
else
jq -nc --arg iface "$iface" \
'{save_path:"/data/torrents",temp_path:"/data/torrents/incomplete",temp_path_enabled:true,auto_tmm_enabled:true,current_network_interface:$iface}'
fi
}
qbit_login_form() {
local cookie_jar="$1"
local user="$2"
local pass="$3"
local qbit_port="${4:-8081}"
curl -s --connect-timeout 5 -c "$cookie_jar" \
-H "Referer: http://${API_HOST}:${qbit_port}" \
--data-urlencode "username=${user}" \
--data-urlencode "password=${pass}" \
"http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true
}
qbit_apply_prefs() {
local cookie_jar="$1"
local extras="${2:-}"
local user="${2:-}"
local pass="${3:-}"
local qbit_port=8081
local json
json=$(qbit_core_prefs_json "$user" "$pass")
curl -s --connect-timeout 5 \
"http://${API_HOST}:${qbit_port}/api/v2/app/setPreferences" \
-b "$cookie_jar" \
-H "Referer: http://${API_HOST}:${qbit_port}" \
-d "json=$(qbit_core_prefs_json "$extras")" \
--data-urlencode "json=${json}" \
2>/dev/null >/dev/null || true
}
@ -101,10 +119,7 @@ qbit_set_credentials() {
# Idempotent: succeed if final credentials already work
if [ -n "$AUTH_USERNAME" ] && [ -n "$AUTH_PASSWORD" ]; then
curl -s --connect-timeout 5 -c "$qbit_cookie_jar" \
-H "Referer: http://${API_HOST}:${qbit_port}" \
-d "username=${AUTH_USERNAME}&password=${AUTH_PASSWORD}" \
"http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true
qbit_login_form "$qbit_cookie_jar" "$AUTH_USERNAME" "$AUTH_PASSWORD" "$qbit_port"
local qbit_sid
qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar")
if [ -n "$qbit_sid" ]; then
@ -128,15 +143,11 @@ qbit_set_credentials() {
qbit_temp_pass=$(docker logs qbittorrent 2>&1 | awk -F': ' '/temporary password is provided for this session:/ { print $NF; exit }' || true)
if [ -n "$qbit_temp_pass" ]; then
curl -s --connect-timeout 5 -c "$qbit_cookie_jar" \
-H "Referer: http://${API_HOST}:${qbit_port}" \
-d "username=admin&password=${qbit_temp_pass}" \
"http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true
qbit_login_form "$qbit_cookie_jar" "admin" "$qbit_temp_pass" "$qbit_port"
local qbit_sid
qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar")
if [ -n "$qbit_sid" ]; then
qbit_apply_prefs "$qbit_cookie_jar" \
",\"web_ui_username\":\"${AUTH_USERNAME}\",\"web_ui_password\":\"${AUTH_PASSWORD}\""
qbit_apply_prefs "$qbit_cookie_jar" "$AUTH_USERNAME" "$AUTH_PASSWORD"
echo >&2
if qbit_vpn_enabled; then
_cfg_log_info "Set qBittorrent credentials, save path, and tun0 bind"
@ -165,10 +176,7 @@ qbit_try_login() {
[ -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
qbit_login_form "$cookie_jar" "$user" "$pass" "$qbit_port"
[ -n "$(qbit_cookie_sid "$cookie_jar")" ]
}
@ -204,8 +212,7 @@ qbit_change_credentials() {
fi
fi
qbit_apply_prefs "$cookie_jar" \
",\"web_ui_username\":\"${new_user}\",\"web_ui_password\":\"${new_pass}\""
qbit_apply_prefs "$cookie_jar" "$new_user" "$new_pass"
rm -f "$cookie_jar"
if qbit_try_login "$new_user" "$new_pass" "$verify_jar"; then
@ -226,10 +233,7 @@ qbit_create_category() {
local qbit_port=8081
local qbit_cookie_jar="/tmp/qb_cookie_jar_${service_name}_$$_${qbit_port}"
curl -s --connect-timeout 10 -c "$qbit_cookie_jar" \
-H "Referer: http://${API_HOST}:${qbit_port}" \
-d "username=${AUTH_USERNAME}&password=${AUTH_PASSWORD}" \
"http://${API_HOST}:${qbit_port}/api/v2/auth/login" 2>/dev/null >/dev/null || true
qbit_login_form "$qbit_cookie_jar" "$AUTH_USERNAME" "$AUTH_PASSWORD" "$qbit_port"
local qbit_sid
qbit_sid=$(qbit_cookie_sid "$qbit_cookie_jar")
if [ -z "$qbit_sid" ]; then

View file

@ -23,7 +23,7 @@ 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)
media|Jellyfin (supported) / Emby / Plex unsupported (restarts stack)
all|Re-run the full setup wizard
EOF
}

View file

@ -215,10 +215,12 @@ configure_jellyfin_libraries() {
# Authenticate to get access token
local auth_response
local auth_payload
auth_payload=$(jq -nc --arg u "$AUTH_USERNAME" --arg p "$AUTH_PASSWORD" '{Username:$u,Pw:$p}')
auth_response=$(curl -sf --connect-timeout 10 -X POST \
-H "Content-Type: application/json" \
-H 'X-Emby-Authorization: MediaBrowser Client="assemblrr", Version="1.0", Device="setup-script", DeviceId="assemblrr-setup"' \
-d "{\"Username\":\"${AUTH_USERNAME}\",\"Pw\":\"${AUTH_PASSWORD}\"}" \
-d "$auth_payload" \
"http://${API_HOST}:${jellyfin_port}/Users/AuthenticateByName" 2>/dev/null || echo "")
if [ -z "$auth_response" ]; then

View file

@ -149,28 +149,17 @@ seerr_get_cookie() {
plex) media_host="host.docker.internal"; media_port=32400; media_type=1 ;;
esac
payload=$(cat <<EOF
{
"username": "${AUTH_USERNAME}",
"password": "${AUTH_PASSWORD}",
"hostname": "${media_host}",
"port": ${media_port},
"useSsl": false,
"urlBase": "",
"email": "",
"serverType": ${media_type}
}
EOF
)
payload=$(jq -nc \
--arg user "$AUTH_USERNAME" \
--arg pass "$AUTH_PASSWORD" \
--arg host "$media_host" \
--argjson port "$media_port" \
--argjson stype "$media_type" \
'{username:$user,password:$pass,hostname:$host,port:$port,useSsl:false,urlBase:"",email:"",serverType:$stype}')
else
# Login-only (for re-runs when media server is already configured)
payload=$(cat <<EOF
{
"username": "${AUTH_USERNAME}",
"password": "${AUTH_PASSWORD}"
}
EOF
)
payload=$(jq -nc --arg user "$AUTH_USERNAME" --arg pass "$AUTH_PASSWORD" \
'{username:$user,password:$pass}')
fi
local auth_endpoint="jellyfin"
@ -365,19 +354,13 @@ configure_seerr() {
# Jellyfin/Emby: use credential-based auth (both use /auth/jellyfin endpoint)
local auth_endpoint="jellyfin"
auth_payload=$(cat <<EOF
{
"username": "${AUTH_USERNAME}",
"password": "${AUTH_PASSWORD}",
"hostname": "${media_server_host}",
"port": ${media_server_port},
"useSsl": false,
"urlBase": "",
"email": "",
"serverType": ${media_server_type}
}
EOF
)
auth_payload=$(jq -nc \
--arg user "$AUTH_USERNAME" \
--arg pass "$AUTH_PASSWORD" \
--arg host "$media_server_host" \
--argjson port "$media_server_port" \
--argjson stype "$media_server_type" \
'{username:$user,password:$pass,hostname:$host,port:$port,useSsl:false,urlBase:"",email:"",serverType:$stype}')
_cfg_log_info "Seerr: authenticating against ${MEDIA_SERVICE:-jellyfin}..."

View file

@ -86,8 +86,8 @@ function Copy-ProjectToWSL2 {
wsl -d $Distro -- bash -c "chmod +x /tmp/$AppName/bin/setup.sh /tmp/$AppName/bin/cli.sh /tmp/$AppName/bin/docker-install.sh /tmp/$AppName/bin/config.sh /tmp/$AppName/lib/*.sh /tmp/$AppName/scripts/*.sh 2>/dev/null"
# Convert CRLF to LF on shell scripts (Windows line endings break bash)
wsl -d $Distro -- bash -c "sed -i 's/\r$//' /tmp/$AppName/.env.example /tmp/$AppName/bin/*.sh /tmp/$AppName/lib/*.sh /tmp/$AppName/branding.conf /tmp/$AppName/compose/*.yaml /tmp/$AppName/compose/examples/*.yaml /tmp/$AppName/templates/*.env /tmp/$AppName/templates/*.yml /tmp/$AppName/scripts/*.sh 2>/dev/null; true"
# Convert CRLF to LF (Windows line endings break bash / Dockerfiles / Python)
wsl -d $Distro -- bash -c "find /tmp/$AppName -type f \( -name '*.sh' -o -name '*.py' -o -name '*.yaml' -o -name '*.yml' -o -name '*.conf' -o -name '.env.example' -o -name 'Dockerfile' -o -name '*.Dockerfile' \) -exec sed -i 's/\r$//' {} + 2>/dev/null; true"
Write-Host "Files copied successfully." -ForegroundColor Green
}

View file

@ -89,7 +89,7 @@ $wslSourcePath = "/mnt/$driveLetter" + ($tempDir.Substring(2) -replace '\\', '/'
wsl -d $defaultDistro -- bash -c "cp -r $wslSourcePath/* /tmp/$AppName/ && cp -r $wslSourcePath/.env.example $wslSourcePath/.gitignore /tmp/$AppName/ 2>/dev/null; true"
# Fix line endings and set permissions
wsl -d $defaultDistro -- bash -c "sed -i 's/\r$//' /tmp/$AppName/.env.example /tmp/$AppName/bin/*.sh /tmp/$AppName/lib/*.sh /tmp/$AppName/branding.conf /tmp/$AppName/compose/*.yaml /tmp/$AppName/compose/examples/*.yaml /tmp/$AppName/templates/*.env /tmp/$AppName/templates/*.yml /tmp/$AppName/scripts/*.sh /tmp/$AppName/platform/linux/bootstrap.sh 2>/dev/null; true"
wsl -d $defaultDistro -- bash -c "find /tmp/$AppName -type f \( -name '*.sh' -o -name '*.py' -o -name '*.yaml' -o -name '*.yml' -o -name '*.conf' -o -name '.env.example' -o -name 'Dockerfile' -o -name '*.Dockerfile' \) -exec sed -i 's/\r$//' {} + 2>/dev/null; true"
wsl -d $defaultDistro -- bash -c "chmod +x /tmp/$AppName/bin/*.sh /tmp/$AppName/lib/*.sh /tmp/$AppName/scripts/*.sh /tmp/$AppName/platform/linux/bootstrap.sh 2>/dev/null; true"
# Cleanup temp directory

View file

@ -8,11 +8,26 @@ PURGE="${SCRIPT_DIR}/media-purge.sh"
export API_HOST="${API_HOST:-127.0.0.1}"
export QBITTORRENT_URL="${QBITTORRENT_URL:-}"
export MEDIA_ROOT="${MEDIA_ROOT:-/data}"
export JELLYFIN_URL="${JELLYFIN_URL:-http://jellyfin:8096}"
export JELLYFIN_API_KEY_FILE="${JELLYFIN_API_KEY_FILE:-/run/secrets/jellyfin_api_key.txt}"
export AUTH_USERNAME_FILE="${AUTH_USERNAME_FILE:-/run/secrets/auth_username.txt}"
export AUTH_PASSWORD_FILE="${AUTH_PASSWORD_FILE:-/run/secrets/auth_password.txt}"
# Inside *arr the app config is /config/config.xml (not /config/sonarr/...).
if [ -f /config/config.xml ]; then
_arr_key=$(sed -n 's/.*<ApiKey>\(.*\)<\/ApiKey>.*/\1/p' /config/config.xml 2>/dev/null | head -1)
if [ -n "${sonarr_eventtype:-}" ]; then
export SONARR_API_KEY="${SONARR_API_KEY:-${_arr_key:-}}"
export SONARR_URL="${SONARR_URL:-http://127.0.0.1:8989}"
fi
if [ -n "${radarr_eventtype:-}" ]; then
export RADARR_API_KEY="${RADARR_API_KEY:-${_arr_key:-}}"
export RADARR_URL="${RADARR_URL:-http://127.0.0.1:7878}"
fi
unset _arr_key
fi
if [ -z "$QBITTORRENT_URL" ]; then
if curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" "http://gluetun:8081/" 2>/dev/null | grep -qE '^[2345]'; then
export QBITTORRENT_URL="http://gluetun:8081"

View file

@ -34,10 +34,19 @@ purge_watch_is_ignored() {
esac
case "$p" in
*/.nfs*|*/.tmp*|*/tmp/*|*/.git/*) return 0 ;;
*/media/.recycle|*/media/.recycle/*|*/.recycle|*/.recycle/*) return 0 ;;
esac
return 1
}
# Title-folder recycle/delete (not an nfo/jpg inside the tree).
purge_watch_is_title_folder() {
local p="${1%/}"
local folder
folder=$(purge_watch_library_folder "$p")
[ -n "$folder" ] && [ "$p" = "$folder" ]
}
purge_watch_is_video() {
local p="${1:-}"
local ext="${p##*.}"
@ -304,10 +313,13 @@ inotifywait -m -r \
handle_event "$full"
continue
fi
# Title folder removed (not a sidecar nfo/jpg delete).
case ",${events}," in
*,DELETE_SELF,*)
handle_event "$full"
;;
esac
# Title folder removed or moved to the recycle bin (MOVED_FROM).
# Sonarr "delete files" often renames media/tv/Show → media/.recycle/...
if purge_watch_is_title_folder "$full"; then
case ",${events}," in
*,DELETE_SELF,*|*,MOVED_FROM,*|*,DELETE,*)
handle_event "$full"
;;
esac
fi
done

View file

@ -169,7 +169,8 @@ qbit_login() {
QBIT_JAR="$jar"
curl -s --connect-timeout 8 -c "$QBIT_JAR" \
-H "Referer: ${QBIT_BASE}/" \
-d "username=${user}&password=${pass}" \
--data-urlencode "username=${user}" \
--data-urlencode "password=${pass}" \
"${QBIT_BASE}/api/v2/auth/login" >/dev/null || true
if ! curl -s --connect-timeout 5 -b "$QBIT_JAR" -H "Referer: ${QBIT_BASE}/" \
"${QBIT_BASE}/api/v2/app/version" 2>/dev/null | grep -q .; then
@ -219,13 +220,14 @@ qb_match_jq() {
def has_year: test("\\([0-9]{4}\\)");
def basename_norm:
gsub("/+$"; "") | split("/") | map(select(length > 0)) | .[-1] // "" | norm;
# After token, only end / " (YYYY" / " [" / " - " / " /" / ".YYYY" — not more title words.
# After token: end, year, tag, separator — or a season token (Loki.S02.COMPLETE).
# "Lost in Space" still fails: remainder is " in space", not a season.
def boundary_ok($t; $tok):
($tok | length) as $m
| ($t | length) as $n
| if $n < $m then false
elif $n == $m then true
else ($t[$m:] | test("^($| \\([0-9]{4}\\)| \\[| - | /|\\.[0-9]{4})"))
else ($t[$m:] | test("^($| \\([0-9]{4}\\)| \\[| - | /|\\.[0-9]{4}| s[0-9]| season |[-.]s[0-9])"))
end;
def folder_hit($text; $tok):
($text | norm) as $t
@ -348,7 +350,7 @@ qbit_filter_hashes_jq() {
| ($t | length) as $n
| if $n < $m then false
elif $n == $m then true
else ($t[$m:] | test("^($| \\([0-9]{4}\\)| \\[| - | /|\\.[0-9]{4})"))
else ($t[$m:] | test("^($| \\([0-9]{4}\\)| \\[| - | /|\\.[0-9]{4}| s[0-9]| season |[-.]s[0-9])"))
end;
def folder_hit($text; $tok):
($tok | length) == 0 or (
@ -386,23 +388,61 @@ qbit_delete_by_folder_key() {
local info hashes hash_count tokens_json
info=$(qbit_torrents_info)
[ -n "$info" ] && [ "$info" != "[]" ] || { log "qB: no torrents"; return 0; }
if [ -n "$info" ] && [ "$info" != "[]" ]; then
tokens_json=$(jq -nc --arg k "$folder_key" '[$k]')
log "qB: folder key: $folder_key"
hashes=$(echo "$info" | qb_match_jq "$tokens_json" 2>/dev/null || true)
if [ -z "${hashes// }" ]; then
log "qB: no torrents matched folder key"
else
hash_count=$(echo "$hashes" | grep -c . || true)
if [ "${hash_count:-0}" -gt 1 ]; then
log "qB: WARNING ${hash_count} torrents matched folder key (refusing multi-delete). hashes=$(echo "$hashes" | tr '\n' ' ')"
else
# shellcheck disable=SC2086
qbit_delete_hashes $hashes
fi
fi
else
log "qB: no torrents (will still remove leftover download dirs)"
fi
remove_orphan_torrent_dirs "$folder_key"
}
# *arr Completed Download Handling often removes the qB item but leaves
# torrents/tv|movies/<release>. Title-delete then finds no qB row.
remove_orphan_torrent_dirs() {
local folder_key="$1"
local seasons_json="${2:-}"
local root="${MEDIA_ROOT:-/data}/torrents"
local d base info tokens_json hashes
[ -n "$folder_key" ] || return 0
[ -d "$root/tv" ] || [ -d "$root/movies" ] || return 0
tokens_json=$(jq -nc --arg k "$folder_key" '[$k]')
log "qB: folder key: $folder_key"
hashes=$(echo "$info" | qb_match_jq "$tokens_json" 2>/dev/null || true)
if [ -z "${hashes// }" ]; then
log "qB: no torrents matched folder key"
return 0
fi
hash_count=$(echo "$hashes" | grep -c . || true)
if [ "${hash_count:-0}" -gt 1 ]; then
log "qB: WARNING ${hash_count} torrents matched folder key (refusing multi-delete). hashes=$(echo "$hashes" | tr '\n' ' ')"
return 0
fi
# shellcheck disable=SC2086
qbit_delete_hashes $hashes
shopt -s nullglob
for d in "$root/tv"/* "$root/movies"/*; do
[ -d "$d" ] || continue
base=$(basename_of "$d")
case "$base" in
tv|movies|incomplete|""|.) continue ;;
esac
info=$(jq -nc --arg n "$base" --arg p "$d" '[{hash:"fs",name:$n,content_path:$p}]')
if [ -n "$seasons_json" ] && [ "$seasons_json" != "[]" ]; then
hashes=$(echo "$info" | qb_match_season_jq "$folder_key" "$seasons_json" 2>/dev/null || true)
else
hashes=$(echo "$info" | qb_match_jq "$tokens_json" 2>/dev/null || true)
fi
[ -n "${hashes// }" ] || continue
log "removing leftover download dir: $d"
if [ "$DRY_RUN" = "1" ]; then
log "DRY_RUN: skip rm $d"
continue
fi
rm -rf -- "$d" && PURGE_TOUCHED=1
done
shopt -u nullglob
}
qbit_delete_by_folder_key_seasons() {
@ -413,19 +453,22 @@ qbit_delete_by_folder_key_seasons() {
local info hashes hash_count
info=$(qbit_torrents_info)
[ -n "$info" ] && [ "$info" != "[]" ] || { log "qB: no torrents"; return 0; }
if [ -n "$info" ] && [ "$info" != "[]" ]; then
log "qB: folder key: $folder_key seasons: $seasons_json"
hashes=$(echo "$info" | qb_match_season_jq "$folder_key" "$seasons_json" 2>/dev/null || true)
log "qB: folder key: $folder_key seasons: $seasons_json"
hashes=$(echo "$info" | qb_match_season_jq "$folder_key" "$seasons_json" 2>/dev/null || true)
if [ -z "${hashes// }" ]; then
log "qB: no torrents matched folder key + seasons"
return 0
if [ -z "${hashes// }" ]; then
log "qB: no torrents matched folder key + seasons"
else
hash_count=$(echo "$hashes" | grep -c . || true)
log "qB: ${hash_count} torrent(s) matched folder key + seasons"
# shellcheck disable=SC2086
qbit_delete_hashes $hashes
fi
else
log "qB: no torrents (will still remove leftover download dirs)"
fi
hash_count=$(echo "$hashes" | grep -c . || true)
log "qB: ${hash_count} torrent(s) matched folder key + seasons"
# shellcheck disable=SC2086
qbit_delete_hashes $hashes
remove_orphan_torrent_dirs "$folder_key" "$seasons_json"
}
# Back-compat name used by older call sites / mental model.
@ -525,20 +568,41 @@ arr_delete_series() {
fi
}
# Remaining seasons from Sonarr series + episodefile JSON (includes specials / 0).
# Used to decide series delete vs season-scoped delete.
seasons_remaining_json() {
local series="${1:-}"
local files="${2:-}"
local out
[ -n "$series" ] || series='{}'
[ -n "$files" ] || files='[]'
out=$(jq -nc --argjson series "$series" --argjson files "$files" '
([ $files[]? | select(.seasonNumber != null) | .seasonNumber ] | unique) as $on_disk
| ([ $series.seasons[]? | select(.monitored == true) | .seasonNumber ] | unique) as $mon
| ($on_disk + $mon) | unique | sort
' 2>/dev/null) || out='[]'
[ -n "$out" ] || out='[]'
printf '%s\n' "$out"
}
# True when this season list covers every remaining on-disk / monitored season
# (specials/0 ignored unless listed). Used to decide series delete vs season.
# (specials/0 count; deleting S01 must not wipe Specials).
seasons_list_covers_remaining() {
local seasons_json="$1"
local remain_json="$2"
jq -en --argjson seasons "$seasons_json" --argjson remain "$remain_json" '
($remain | length) > 0
and (($remain - $seasons) | length) == 0
' >/dev/null 2>&1
}
seasons_cover_remaining() {
local key="$1" id="$2" seasons_json="$3"
local series files
local series files remain
series=$(arr_get "$SONARR_URL" "$key" "/api/v3/series/${id}")
files=$(arr_get "$SONARR_URL" "$key" "/api/v3/episodefile?seriesId=${id}")
echo "$series" | jq -e --argjson seasons "$seasons_json" --argjson files "${files:-[]}" '
([ $files[]? | .seasonNumber ] | unique) as $on_disk
| ([ .seasons[]? | select(.monitored == true) | .seasonNumber ]) as $mon
| (($on_disk + $mon) | unique | map(select(. != 0 or ($seasons | index(0)) != null))) as $remain
| ($remain | length) > 0
and (all($remain[]; ($seasons | index(.)) != null))
' >/dev/null 2>&1
remain=$(seasons_remaining_json "$series" "${files:-[]}")
seasons_list_covers_remaining "$seasons_json" "$remain"
}
arr_unmonitor_seasons() {
@ -661,11 +725,13 @@ purge_qb() {
if [ "${#kept[@]}" -gt 0 ]; then
log "qB: history hashes validated (${#kept[@]}): ${kept[*]}"
qbit_delete_hashes "${kept[@]}"
remove_orphan_torrent_dirs "$folder" "$seasons_json"
return 0
fi
else
log "qB: history hashes validated (${#filtered[@]}): ${filtered[*]}"
qbit_delete_hashes "${filtered[@]}"
remove_orphan_torrent_dirs "$folder" "$seasons_json"
return 0
fi
fi
@ -824,6 +890,71 @@ if [ "${1:-}" = "--match-self-test" ]; then
check_season "hash_e1 hash_e2" "multiple S01E0x torrents all match" \
"Loki" '[1]' \
'[{"hash":"hash_e1","name":"Loki.S01E01","content_path":"/data/torrents/tv/Loki.S01E01"},{"hash":"hash_e2","name":"Loki.S01E02","content_path":"/data/torrents/tv/Loki.S01E02"}]'
check "hash_s2" "title-level Loki matches S02 complete pack" \
'["Loki"]' \
'[{"hash":"hash_s2","name":"Loki.S02.COMPLETE.1080p.DSNP.WEB-DL","content_path":"/data/torrents/tv/Loki.S02.COMPLETE.1080p.DSNP.WEB-DL"}]'
check "" "title-level Lost still does not match Lost in Space pack" \
'["Lost"]' \
'[{"hash":"hash_lis","name":"Lost in Space.S01.COMPLETE","content_path":"/data/torrents/tv/Lost in Space.S01.COMPLETE"}]'
echo "=== media-purge remaining-season cover ==="
cover_check() {
local want="$1" name="$2" seasons="$3" series="$4" files="$5"
local remain got
remain=$(seasons_remaining_json "$series" "$files")
if seasons_list_covers_remaining "$seasons" "$remain"; then
got="cover"
else
got="partial"
fi
if [ "$got" = "$want" ]; then
echo " PASS: $name"
else
echo " FAIL: $name (want='$want' got='$got' remain='$remain')"
failures=$((failures + 1))
fi
}
_series_s01s02='{"seasons":[{"seasonNumber":1,"monitored":true},{"seasonNumber":2,"monitored":true}]}'
_files_s01s02='[{"seasonNumber":1},{"seasonNumber":2}]'
_series_s01_spec='{"seasons":[{"seasonNumber":0,"monitored":true},{"seasonNumber":1,"monitored":true}]}'
_files_s01_spec='[{"seasonNumber":0},{"seasonNumber":1}]'
_series_spec_only='{"seasons":[{"seasonNumber":0,"monitored":true}]}'
_files_spec_only='[{"seasonNumber":0}]'
cover_check "partial" "S01 request does not cover S01+S02" \
'[1]' "$_series_s01s02" "$_files_s01s02"
cover_check "cover" "S01+S02 request covers S01+S02" \
'[1,2]' "$_series_s01s02" "$_files_s01s02"
cover_check "partial" "S01 request does not cover S01+specials" \
'[1]' "$_series_s01_spec" "$_files_s01_spec"
cover_check "cover" "S01+specials request covers both" \
'[0,1]' "$_series_s01_spec" "$_files_s01_spec"
cover_check "partial" "S01 request does not cover specials-only leftover" \
'[1]' "$_series_spec_only" "$_files_spec_only"
cover_check "cover" "specials request covers specials-only leftover" \
'[0]' "$_series_spec_only" "$_files_spec_only"
echo "=== media-purge leftover download dirs ==="
_old_root="${MEDIA_ROOT:-}"
MEDIA_ROOT=$(mktemp -d)
mkdir -p "$MEDIA_ROOT/torrents/tv/Loki.S02.COMPLETE.1080p.DSNP.WEB-DL" \
"$MEDIA_ROOT/torrents/tv/Lost in Space.S01.COMPLETE"
DRY_RUN=0
remove_orphan_torrent_dirs "Loki"
if [ ! -d "$MEDIA_ROOT/torrents/tv/Loki.S02.COMPLETE.1080p.DSNP.WEB-DL" ]; then
echo " PASS: Loki leftover pack dir removed"
else
echo " FAIL: Loki leftover pack dir still present"
failures=$((failures + 1))
fi
if [ -d "$MEDIA_ROOT/torrents/tv/Lost in Space.S01.COMPLETE" ]; then
echo " PASS: Lost in Space leftover left alone"
else
echo " FAIL: Lost in Space leftover was removed"
failures=$((failures + 1))
fi
rm -rf "$MEDIA_ROOT"
MEDIA_ROOT="${_old_root}"
if [ "$failures" -gt 0 ]; then
echo "match-self-test: FAILED ($failures)"
exit 1

View file

@ -346,7 +346,7 @@ def sonarr_lookup_series_id(payload: dict, api_key: str) -> Optional[int]:
def sonarr_remaining_seasons(series_id: int, api_key: str) -> List[int]:
"""Seasons that still have files or are monitored (skip specials)."""
"""Seasons that still have files or are monitored (includes specials / 0)."""
status, series = _sonarr_request("GET", f"/api/v3/series/{series_id}", api_key)
if status == 404:
return []
@ -359,8 +359,12 @@ def sonarr_remaining_seasons(series_id: int, api_key: str) -> List[int]:
raise RuntimeError(f"sonarr GET episodefile HTTP {status_f}")
remain: set = set()
for f in files:
if isinstance(f, dict) and f.get("seasonNumber") not in (None, 0):
if not isinstance(f, dict) or f.get("seasonNumber") is None:
continue
try:
remain.add(int(f["seasonNumber"]))
except (TypeError, ValueError):
continue
for s in series.get("seasons") or []:
if not isinstance(s, dict):
continue
@ -368,8 +372,6 @@ def sonarr_remaining_seasons(series_id: int, api_key: str) -> List[int]:
n = int(s.get("seasonNumber"))
except (TypeError, ValueError):
continue
if n == 0:
continue
if s.get("monitored"):
remain.add(n)
return sorted(remain)
@ -1148,6 +1150,99 @@ def self_test() -> int:
"S02 file still in mock Sonarr",
)
# S01 + specials on disk: deleting the S01 request must not title-wipe specials.
_reset_mock_sonarr()
_MockSonarr.files = [
{"id": 1, "seasonNumber": 1, "seriesId": 10},
{"id": 3, "seasonNumber": 0, "seriesId": 10},
]
_MockSonarr.series = {
"id": 10,
"title": "Loki",
"tvdbId": 362472,
"seasons": [
{"seasonNumber": 0, "monitored": True},
{"seasonNumber": 1, "monitored": True},
],
}
_MockSeerr.request_payload = {
"id": 8,
"type": "tv",
"is4k": False,
"seasons": [{"seasonNumber": 1, "status": 2}],
"media": {
"id": 6,
"tmdbId": 84958,
"tvdbId": 362472,
"mediaType": "tv",
"externalServiceId": 10,
},
}
_MockSeerr.calls = []
_MockSonarr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/8",
{"X-Api-Key": "test-key"},
)
check(st == 204, f"TV S01 with specials → 204 (got {st})")
check(
not any("DELETE /api/v1/media/6/file" in c for c in _MockSeerr.calls),
"TV S01 with specials does not title-delete",
)
check(
any(c == "DELETE /api/v3/episodefile/1" for c in _MockSonarr.calls),
"TV S01 with specials deletes S01 file",
)
check(
not any(c == "DELETE /api/v3/episodefile/3" for c in _MockSonarr.calls),
"TV S01 with specials keeps specials file",
)
check(
any(f.get("seasonNumber") == 0 for f in _MockSonarr.files),
"specials file still in mock Sonarr",
)
check(
"seasons_deleted" in (hdrs.get("X-Assemblrr-Purge-Detail") or ""),
f"S01+specials purge is season-scoped ({hdrs.get('X-Assemblrr-Purge-Detail')})",
)
# Only specials remain: last regular-season request still must not title-delete.
_reset_mock_sonarr()
_MockSonarr.files = [{"id": 3, "seasonNumber": 0, "seriesId": 10}]
_MockSonarr.series = {
"id": 10,
"title": "Loki",
"tvdbId": 362472,
"seasons": [
{"seasonNumber": 0, "monitored": True},
{"seasonNumber": 1, "monitored": False},
],
}
_MockSeerr.request_payload = {
"id": 8,
"type": "tv",
"is4k": False,
"seasons": [{"seasonNumber": 1}],
"media": {"id": 6, "mediaType": "tv", "externalServiceId": 10, "tvdbId": 362472},
}
_MockSeerr.calls = []
_MockSonarr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/8",
{"X-Api-Key": "test-key"},
)
check(st == 204, f"S01 request with only specials left → 204 (got {st})")
check(
not any("DELETE /api/v1/media/6/file" in c for c in _MockSeerr.calls),
"specials-only leftover does not title-delete",
)
check(
any(f.get("seasonNumber") == 0 for f in _MockSonarr.files),
"specials survive last regular-season request delete",
)
# TV request covering every remaining season → title-level delete.
_reset_mock_sonarr()
_MockSeerr.request_payload = {

View file

@ -39,10 +39,11 @@ login_qbit() {
local http_code
http_code=$(curl -s -o /tmp/qbit_watchdog_login_body.txt -w "%{http_code}" -c "$COOKIE_JAR" \
-H "Referer: http://${QBIT_HOST}" \
--data "username=${QBIT_USER}&password=${QBIT_PASS}" \
--data-urlencode "username=${QBIT_USER}" \
--data-urlencode "password=${QBIT_PASS}" \
"http://${QBIT_HOST}/api/v2/auth/login" 2>/dev/null || true)
if [ "$http_code" != "204" ]; then
if [ "$http_code" != "200" ] && [ "$http_code" != "204" ]; then
return 1
fi

View file

@ -197,7 +197,8 @@ qbit_login() {
http_code=$(curl -s -o /tmp/assemblrr_qbit_login_body.txt -w "%{http_code}" \
-c "$cookie_jar" \
-H "Referer: http://${QBIT_HOST}" \
--data "username=${user}&password=${pass}" \
--data-urlencode "username=${user}" \
--data-urlencode "password=${pass}" \
"${QBIT_API}/auth/login" 2>/dev/null || echo "000")
[ "$http_code" = "200" ] || [ "$http_code" = "204" ]
}

View file

@ -42,10 +42,23 @@ assert_contains "$joined" "vpn.yaml" "VPN=Y treated as enabled"
test_suite "recyclarr image tag"
recyclarr_image=$(grep -E '^\s+image:.*recyclarr' "$REPO_ROOT/compose/base.yaml")
assert_contains "$recyclarr_image" "ghcr.io/recyclarr/recyclarr:8" "pins major tag 8"
assert_contains "$recyclarr_image" "ghcr.io/recyclarr/recyclarr:8.7.1" "pins recyclarr 8.7.1"
assert_not_contains "$recyclarr_image" ":latest" "does not use unpublished :latest"
assert_contains "$(grep CRON "$REPO_ROOT/compose/base.yaml")" "CRON_SCHEDULE" "uses CRON_SCHEDULE"
test_suite "default stack images are version-pinned"
images=$(grep -E '^\s+image:' "$REPO_ROOT/compose/base.yaml" "$REPO_ROOT/compose/vpn.yaml")
assert_not_contains "$images" ":latest" "no :latest in default compose"
assert_contains "$images" "linuxserver/jellyfin:10.11.11" "jellyfin pinned"
assert_contains "$images" "linuxserver/qbittorrent:5.2.3" "qbittorrent pinned"
assert_contains "$images" "seerr-team/seerr:v3.4.1" "seerr pinned"
assert_contains "$images" "python:3.12.14-alpine" "gateway python pinned"
assert_contains "$images" "gluetun:v3.41.3" "gluetun pinned"
assert_contains "$images" "deunhealth:v0.3.0" "deunhealth pinned"
# Local sidecar tags are not registry floats
assert_contains "$images" "assemblrr/media-purge-watch:local" "purge sidecar is local"
assert_contains "$images" "assemblrr/vpn-watchdog:local" "watchdog sidecar is local"
test_suite "local sidecar images do not pull from a registry"
assert_contains "$(awk '/media-purge-watch:/,/^ [a-z]/{print}' "$REPO_ROOT/compose/base.yaml")" \
"pull_policy: build" "media-purge-watch builds locally"

View file

@ -89,4 +89,33 @@ assert_true "config apply is a subcommand" "grep -q 'apply|wire)' \"$REPO_ROOT/b
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_suite "config show survives missing VPN_TYPE/TZ"
cli="$REPO_ROOT/bin/cli.sh"
assert_contains "$(grep -n 'VPN_TYPE=' "$cli" | head -5)" 'VPN_TYPE="${VPN_TYPE:-openvpn}"' "CLI defaults VPN_TYPE"
assert_contains "$(grep -n 'TZ=' "$cli" | head -5)" 'TZ="${TZ:-UTC}"' "CLI defaults TZ"
show_fn=$(sed -n '/^show_config()/,/^}/p' "$cli")
assert_contains "$show_fn" '${VPN_TYPE:-openvpn}' "show_config defaults VPN type"
assert_contains "$show_fn" '${TZ:-UTC}' "show_config defaults timezone"
# Reproduce the old set -u crash: print those fields with vars unset.
out=$(bash -c 'set -u
MEDIA_SERVICE=jellyfin
VPN_ENABLED=n
# VPN_TYPE and TZ deliberately unset
echo " Media service: ${MEDIA_SERVICE:-jellyfin}"
echo " VPN enabled: ${VPN_ENABLED:-n}"
echo " VPN type: ${VPN_TYPE:-openvpn}"
echo " Timezone: ${TZ:-UTC}"
')
assert_contains "$out" "VPN type: openvpn" "unset VPN_TYPE prints default"
assert_contains "$out" "Timezone: UTC" "unset TZ prints default"
test_suite "seerr and jellyfin first-run auth use jq --arg"
assert_contains "$(sed -n '/^seerr_get_cookie()/,/^}/p' "$REPO_ROOT/lib/seerr.sh")" \
'--arg pass' "seerr cookie payload jq-escapes password"
assert_contains "$(sed -n '/^configure_seerr()/,/^seerr_verify_login()/p' "$REPO_ROOT/lib/seerr.sh")" \
'--arg pass' "seerr setup payload jq-escapes password"
assert_contains "$(sed -n '/^configure_jellyfin_libraries()/,/^}/p' "$REPO_ROOT/lib/jellyfin.sh")" \
'jq -nc --arg u' "jellyfin library auth jq-escapes password"
test_summary

View file

@ -64,4 +64,16 @@ assert_eq "0" "$(purge_watch_season_number '/data/media/tv/Show Name/Specials/ep
assert_eq "" "$(purge_watch_season_number '/data/media/tv/Show Name/ep.mkv')" "no season folder"
assert_eq "" "$(purge_watch_season_number '/data/media/movies/Title (2012)/movie.mkv')" "movie path"
test_suite "purge_watch recycle and title-folder events"
assert_true "recycle path ignored" \
"purge_watch_is_ignored '/data/media/.recycle/Loki'"
assert_true "recycle file ignored" \
"purge_watch_is_ignored '/data/media/.recycle/Loki/Season 2/ep.mkv'"
assert_true "tv title folder" \
"purge_watch_is_title_folder '/data/media/tv/Loki'"
assert_true "movie title folder" \
"purge_watch_is_title_folder '/data/media/movies/Title (2012)'"
assert_false "episode path is not the title folder" \
"purge_watch_is_title_folder '/data/media/tv/Loki/Season 2/ep.mkv'"
test_summary

View file

@ -29,9 +29,19 @@ assert_contains "$json" '"auto_tmm_enabled":true' "ATM still on without VPN"
test_suite "qbit_core_prefs_json extras merge"
VPN_ENABLED=y
json=$(qbit_core_prefs_json ',"web_ui_username":"x"')
json=$(qbit_core_prefs_json "x" 'p&q"r')
assert_contains "$json" '"current_network_interface":"tun0"' "tun0 kept with extras"
assert_contains "$json" '"web_ui_username":"x"' "extras appended"
assert_contains "$json" '"web_ui_username":"x"' "username field set"
assert_contains "$json" '"web_ui_password":"p&q\"r"' "password JSON-escaped"
test_suite "qbit login form encodes reserved characters"
src=$(sed -n '/^qbit_login_form()/,/^}/p' "$REPO_ROOT/lib/arr.sh")
assert_contains "$src" "--data-urlencode" "qbit_login_form url-encodes fields"
assert_not_contains "$src" '-d "username=' "qbit_login_form does not raw-concat form body"
src=$(sed -n '/^qbit_apply_prefs()/,/^}/p' "$REPO_ROOT/lib/arr.sh")
assert_contains "$src" "--data-urlencode" "setPreferences url-encodes json="
assert_contains "$src" "qbit_core_prefs_json" "prefs built via jq helper"
test_suite "qbit_vpn_enabled"
VPN_ENABLED=y

View file

@ -115,4 +115,22 @@ refresh=$(sed -n '/^refresh_user_cli()/,/^}/p' "$REPO_ROOT/lib/upgrade.sh")
assert_contains "$refresh" "install_user_cli_wrapper" "refresh writes PATH wrapper"
assert_not_contains "$refresh" "list_cli_lib_modules" "refresh does not copy PATH libs"
test_suite "arr-purge-hook reads in-container API key and media root"
hook=$(cat "$REPO_ROOT/scripts/arr-purge-hook.sh")
assert_contains "$hook" "/config/config.xml" "hook reads /config/config.xml"
assert_contains "$hook" "MEDIA_ROOT=" "hook sets MEDIA_ROOT"
assert_contains "$hook" "SONARR_API_KEY" "hook exports Sonarr key"
test_suite "Windows bootstrap strips CRLF on new file types"
win=$(cat "$REPO_ROOT/platform/windows/bootstrap.ps1")
dev=$(cat "$REPO_ROOT/platform/windows/bootstrap-dev.ps1")
assert_contains "$win" "find /tmp/\$AppName -type f" "bootstrap.ps1 uses find for CRLF strip"
assert_contains "$win" "-name '*.py'" "bootstrap.ps1 strips Python"
assert_contains "$win" "-name '*.Dockerfile'" "bootstrap.ps1 strips sidecar Dockerfiles"
assert_contains "$dev" "find /tmp/\$AppName -type f" "bootstrap-dev.ps1 uses find for CRLF strip"
assert_contains "$dev" "-name '*.py'" "bootstrap-dev.ps1 strips Python"
assert_contains "$dev" "-name '*.Dockerfile'" "bootstrap-dev.ps1 strips sidecar Dockerfiles"
assert_not_contains "$win" "sed -i 's/\\r\$//' /tmp/\$AppName/.env.example /tmp/\$AppName/bin/*.sh" \
"bootstrap.ps1 no longer uses the stale glob list"
test_summary