mirror of
https://github.com/soulis-1256/assemblrr.git
synced 2026-10-19 09:13:33 +00:00
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:
parent
7d2d0d1770
commit
edac50f8a2
23 changed files with 494 additions and 159 deletions
14
README.md
14
README.md
|
|
@ -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
|
||||
|
|
|
|||
10
bin/cli.sh
10
bin/cli.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
66
lib/arr.sh
66
lib/arr.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
lib/seerr.sh
49
lib/seerr.sh
|
|
@ -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}..."
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" ]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue