Add media purge cascade, seed defaults, and wire on upgrade.

Hardlink-safe cleanup: Seerr/Jellyfin/*arr deletes remove library files and
qB torrents via purge hooks and media-purge-watch. Indexers get seed ratio/time
so completed downloads can leave torrents/. Upgrade always re-wires services;
drop user-facing config sync. Live e2e covers Radarr, Seerr, and Jellyfin APIs.
This commit is contained in:
soulis-1256 2026-08-12 13:19:49 +03:00
parent c00377844e
commit c180732db1
No known key found for this signature in database
17 changed files with 1188 additions and 102 deletions

View file

@ -67,12 +67,11 @@ After install, the CLI is on your `PATH` as `assemblrr`. Run `assemblrr <command
| `config` | List config subcommands |
| `config show` | Show current configuration |
| `config edit` | Re-run the setup wizard (current values as defaults) |
| `config sync` | Re-wire service APIs (Radarr, Sonarr, Prowlarr, Seerr, …) |
| `backup /target/dir` | Snapshot configuration |
| `restore /backup.tar.gz` | Restore from a backup archive |
| `update-containers` | Pull latest images and restart (offers a backup first) |
| `update-cli` | Update the CLI to the latest version |
| `upgrade` | Upgrade install files from git `main` (or `--from DIR`); backup + migrations |
| `upgrade` | Upgrade from git `main` (or `--from DIR`): files, migrations, stack restart, service wiring |
| `upgrade --check` | Dry-run: show which managed files and migrations would run |
| `check-vpn` | Poll VPN health |
| `uninstall` | Remove everything (asks before deleting data) |

View file

@ -80,7 +80,7 @@ declare -A COMMANDS=(
["upgrade"]="upgrade install files from git (or --from DIR); runs migrations"
["logs"]="shows container logs (optionally specify service name)"
["health"]="checks health status of all services"
["config"]="configuration: show options, or run show / edit / sync"
["config"]="configuration: show options, or run show / edit"
)
# log_error_inline — same as log_error but without exit (used by check_health)
@ -121,7 +121,45 @@ show_help() {
echo " ${APP_CLI_NAME} config # List config subcommands"
echo " ${APP_CLI_NAME} config show # Show current configuration"
echo " ${APP_CLI_NAME} config edit # Re-run setup wizard"
echo " ${APP_CLI_NAME} config sync # Re-wire service APIs"
echo " ${APP_CLI_NAME} purge --movie-id N # Full delete (*arr + disk + qB)"
echo " ${APP_CLI_NAME} purge --path PATH # Full delete by library path"
echo " ${APP_CLI_NAME} purge --tmdb ID # Full delete by TMDB id"
}
# Full media purge (*arr + qB + hardlinks). Thin wrapper around scripts/media-purge.sh
purge_media() {
local install_dir script
install_dir=$(find_install_directory)
[ -n "$install_dir" ] || log_error "Could not find installation directory"
script="$install_dir/scripts/media-purge.sh"
if [ ! -f "$script" ]; then
# Dev tree: repo scripts/
local self_dir
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$self_dir/../scripts/media-purge.sh" ]; then
script="$self_dir/../scripts/media-purge.sh"
fi
fi
[ -f "$script" ] || log_error "media-purge.sh not found — run upgrade or re-setup"
chmod +x "$script" 2>/dev/null || true
set -a
# shellcheck disable=SC1091
[ -f "$install_dir/.env" ] && . "$install_dir/.env"
set +a
export API_HOST="${API_HOST:-127.0.0.1}"
export CONFIG_DIR="$install_dir/config"
export INSTALL_DIR="$install_dir"
export MEDIA_ROOT="${MEDIA_DIRECTORY:-/data}"
if [ -f "$install_dir/secrets/auth_username.txt" ]; then
export AUTH_USERNAME_FILE="$install_dir/secrets/auth_username.txt"
export AUTH_PASSWORD_FILE="$install_dir/secrets/auth_password.txt"
fi
export QBITTORRENT_URL="${QBITTORRENT_URL:-http://127.0.0.1:8081}"
export RADARR_URL="${RADARR_URL:-http://127.0.0.1:7878}"
export SONARR_URL="${SONARR_URL:-http://127.0.0.1:8989}"
bash "$script" "$@"
}
wait_for_services() {
@ -652,13 +690,11 @@ show_config_help() {
echo "Subcommands:"
printf " %-12s %s\n" "show" "Show current configuration"
printf " %-12s %s\n" "edit" "Re-run the setup wizard (current values as defaults)"
printf " %-12s %s\n" "sync" "Re-wire service APIs (Radarr, Sonarr, Prowlarr, Bazarr, Seerr, …)"
echo
echo "Examples:"
echo " ${APP_CLI_NAME} config # List config options"
echo " ${APP_CLI_NAME} config show # Show configuration"
echo " ${APP_CLI_NAME} config edit # Change install choices"
echo " ${APP_CLI_NAME} config sync # Repair / re-apply app wiring"
}
# Re-run setup wizard (write files, restart stack, wire apps)
@ -682,18 +718,6 @@ config_edit() {
bash "$setup_script"
}
# Re-wire service APIs only (post-install automation)
config_sync() {
if [ -f "$INSTALL_DIR/config.sh" ]; then
bash "$INSTALL_DIR/config.sh"
elif [ -f "$INSTALL_DIR/configure.sh" ]; then
# Legacy install name
bash "$INSTALL_DIR/configure.sh"
else
log_error "config.sh not found in $INSTALL_DIR"
fi
}
config_cmd() {
local sub=${1:-}
case "$sub" in
@ -706,9 +730,6 @@ config_cmd() {
edit)
config_edit
;;
sync)
config_sync
;;
--help|-h|help)
show_config_help
;;
@ -901,14 +922,9 @@ main() {
config)
config_cmd "${2:-}"
;;
# Deprecated aliases (hidden from help)
reconfigure)
log_warning "'reconfigure' is deprecated — use: ${APP_CLI_NAME} config edit"
config_edit
;;
configure)
log_warning "'configure' is deprecated — use: ${APP_CLI_NAME} config sync"
config_sync
purge)
[ $# -lt 2 ] && log_error "Usage: ${APP_CLI_NAME} purge --movie-id N | --series-id N | --path PATH | --tmdb ID | --tvdb ID"
purge_media "${@:2}"
;;
*)
log_error "Unknown command: $command\nRun '${APP_CLI_NAME} --help' for usage information"

View file

@ -1,9 +1,8 @@
#!/bin/bash
set -euo pipefail
# assemblrr service wiring — setup + `assemblrr config sync`
# Service wiring — invoked by setup and upgrade.
# Exit 0 if all required steps succeed; exit 1 if any required step fails.
# Required failures are counted and the script keeps going so you see a full report.
# --- Config discovery ---
@ -166,10 +165,12 @@ fi
if [ -n "$RADARR_API_KEY" ]; then
run_critical configure_radarr "$RADARR_API_KEY"
run_optional add_arr_purge_hook "Radarr" "7878" "$RADARR_API_KEY"
fi
if [ -n "$SONARR_API_KEY" ]; then
run_critical configure_sonarr "$SONARR_API_KEY"
run_optional add_arr_purge_hook "Sonarr" "8989" "$SONARR_API_KEY"
fi
if [ -n "$PROWLARR_API_KEY" ]; then
@ -218,7 +219,7 @@ if [ "$_wire_critical_fail" -gt 0 ]; then
fi
echo -e " ${YELLOW}Log: $CONFIGURE_LOG${NC}" | tee -a "$CONFIGURE_LOG"
echo
_cfg_log_info "Fix issues, then: ${APP_CLI_NAME:-assemblrr} config sync"
_cfg_log_info "Fix issues, then re-run setup or: ${APP_CLI_NAME:-assemblrr} upgrade"
echo
exit 1
fi

View file

@ -258,6 +258,9 @@ copy_configuration_files() {
["bin/docker-install.sh"]="docker-install.sh"
["scripts/jellyfin-refresh.sh"]="scripts/jellyfin-refresh.sh"
["scripts/vpn-watchdog.sh"]="scripts/vpn-watchdog.sh"
["scripts/media-purge.sh"]="scripts/media-purge.sh"
["scripts/arr-purge-hook.sh"]="scripts/arr-purge-hook.sh"
["scripts/media-purge-watch.sh"]="scripts/media-purge-watch.sh"
)
for src in "${!files[@]}"; do
@ -695,13 +698,13 @@ if [ -f "$install_directory/config.sh" ]; then
if ! bash "$install_directory/config.sh"; then
wiring_ok=0
log_warning "Service wiring had critical failures. Containers are still running."
log_warning "Fix the issues above, then re-run: $APP_CLI_NAME config sync"
log_warning "Fix the issues above, then re-run setup or: $APP_CLI_NAME upgrade"
fi
fi
# Drop legacy name if an older install left it behind
rm -f "$install_directory/configure.sh" 2>/dev/null || true
# Install CLI (needed for config sync even if wiring failed)
# Install CLI (needed for status/upgrade even if wiring failed)
echo
log_info "Installing CLI and configuring permissions..."
install_cli
@ -739,7 +742,7 @@ fi
log_warning "Setup finished, but service wiring failed."
log_warning "Install directory: $install_directory"
log_warning "Re-run wiring: $APP_CLI_NAME config sync"
log_warning "Fix issues, then re-run setup or: $APP_CLI_NAME upgrade"
log_info "Docs: ${APP_REPO_URL}"
exit 1
}

View file

@ -109,7 +109,7 @@ services:
- /etc/localtime:/etc/localtime:ro
- ${MEDIA_DIRECTORY}:/data
- ${INSTALL_DIRECTORY}/config/sonarr:/config
- ${INSTALL_DIRECTORY}/scripts/jellyfin-refresh.sh:/scripts/jellyfin-refresh.sh:ro
- ${INSTALL_DIRECTORY}/scripts:/scripts:ro
- ${INSTALL_DIRECTORY}/secrets:/run/secrets:ro
ports:
- 8989:8989
@ -136,7 +136,7 @@ services:
- /etc/localtime:/etc/localtime:ro
- ${MEDIA_DIRECTORY}:/data
- ${INSTALL_DIRECTORY}/config/radarr:/config
- ${INSTALL_DIRECTORY}/scripts/jellyfin-refresh.sh:/scripts/jellyfin-refresh.sh:ro
- ${INSTALL_DIRECTORY}/scripts:/scripts:ro
- ${INSTALL_DIRECTORY}/secrets:/run/secrets:ro
ports:
- 7878:7878
@ -235,6 +235,37 @@ services:
assemblrr_network:
ipv4_address: 172.60.0.18
# Deletes under /data/media → purge *arr entry + qB torrent/files
media-purge-watch:
image: alpine:3
container_name: media-purge-watch
environment:
- TZ=${TZ}
- MEDIA_ROOT=/data
- CONFIG_DIR=/config
- RADARR_URL=http://radarr:7878
- SONARR_URL=http://sonarr:8989
- AUTH_USERNAME_FILE=/run/secrets/auth_username.txt
- AUTH_PASSWORD_FILE=/run/secrets/auth_password.txt
volumes:
- /etc/localtime:/etc/localtime:ro
- ${MEDIA_DIRECTORY}:/data
- ${INSTALL_DIRECTORY}/config:/config:ro
- ${INSTALL_DIRECTORY}/scripts:/scripts:ro
- ${INSTALL_DIRECTORY}/secrets:/run/secrets:ro
command:
- /bin/sh
- -c
- |
set -eu
apk add --no-cache bash curl jq inotify-tools >/dev/null
chmod +x /scripts/media-purge.sh /scripts/media-purge-watch.sh /scripts/arr-purge-hook.sh 2>/dev/null || true
exec bash /scripts/media-purge-watch.sh
restart: unless-stopped
networks:
assemblrr_network:
ipv4_address: 172.60.0.23
# Bazarr — automatic subtitle management (Sonarr/Radarr companion)
bazarr:
image: lscr.io/linuxserver/bazarr

View file

@ -2,7 +2,7 @@
assemblrr uses [Recyclarr](https://recyclarr.dev/) to sync [TRaSH Guides](https://trash-guides.info/) quality profiles and custom formats into Radarr and Sonarr.
**Subtitles:** Bazarr is wired separately (not via Recyclarr). On `config sync` / first install, assemblrr applies [TRaSH Bazarr suggested scoring](https://trash-guides.info/Bazarr/Bazarr-suggested-scoring/) (series min score 90, movies 80, auto-sync thresholds 96/86) plus free multi-provider defaults and optional OpenSubtitles.com credentials from setup.
**Subtitles:** Bazarr is wired separately (not via Recyclarr). On first install and every `assemblrr upgrade`, assemblrr applies [TRaSH Bazarr suggested scoring](https://trash-guides.info/Bazarr/Bazarr-suggested-scoring/) (series min score 90, movies 80, auto-sync thresholds 96/86) plus free multi-provider defaults and optional OpenSubtitles.com credentials from setup.
**Both resolutions are always installed** as assemblrr-named profiles (movies: **assemblrr HD Bluray + WEB** + **assemblrr UHD Bluray + WEB**; TV: **assemblrr WEB-1080p** + **assemblrr WEB-2160p**). The setup question only picks Seerr’s *default* for movie requests — the other profile stays available in the quality dropdown. Stock Radarr/Sonarr profiles (Any, HD-1080p, …) also appear there; that is normal.
@ -25,6 +25,6 @@ That is expected community-guide churn — not a VPN or Docker failure.
docker exec recyclarr recyclarr list quality-profiles sonarr
```
3. Update the relevant pack under `templates/recyclarr/includes/` (or the live copies in your install’s `config/recyclarr/includes/`) with the new trash IDs. Root config is `templates/recyclarr/recyclarr.yml`.
4. Re-run Recyclarr, then refresh app wiring: `assemblrr config sync`
4. Re-run Recyclarr, then refresh the install (wiring included): `assemblrr upgrade`
We try to keep templates current, but **plan on occasional manual updates** if you rely on TRaSH-backed profiles long-term. Official docs: [Recyclarr](https://recyclarr.dev/) · [TRaSH Guides](https://trash-guides.info/).

View file

@ -217,39 +217,39 @@ configure_arr_service() {
local existing_impls
existing_impls=$(jq_json_list_values "$download_clients" "implementationName")
if ! echo "$existing_impls" | grep -q "qBittorrent"; then
# Build download client payload — category field names differ between Radarr and Sonarr
local qbit_payload
qbit_payload=$(jq -n --arg host "$QBITTORRENT_HOST" --arg user "$AUTH_USERNAME" --arg pass "$AUTH_PASSWORD" \
--arg cat_name "$category_name" --arg sn "$service_name" '
{
"enable": true,
"removeCompletedDownloads": true,
"removeFailedDownloads": true,
"name": "qBittorrent",
"implementation": "QBittorrent",
"implementationName": "qBittorrent",
"configContract": "QBittorrentSettings",
"priority": 1,
"fields": [
{"name": "host", "value": $host},
{"name": "port", "value": 8081},
{"name": "useSsl", "value": false},
{"name": "urlBase", "value": ""},
{"name": "username", "value": $user},
{"name": "password", "value": $pass},
{"name": (if $sn == "Radarr" then "movieCategory" else "tvCategory" end), "value": $cat_name},
{"name": (if $sn == "Radarr" then "movieImportedCategory" else "tvImportedCategory" end), "value": ""},
{"name": (if $sn == "Radarr" then "recentMoviePriority" else "recentTvPriority" end), "value": 0},
{"name": (if $sn == "Radarr" then "olderMoviePriority" else "olderTvPriority" end), "value": 0},
{"name": "initialState", "value": 0},
{"name": "sequentialOrder", "value": false},
{"name": "firstAndLast", "value": false},
{"name": "contentLayout", "value": 0}
]
}
' 2>/dev/null || echo "")
# Build download client payload — category field names differ between Radarr and Sonarr
local qbit_payload
qbit_payload=$(jq -n --arg host "$QBITTORRENT_HOST" --arg user "$AUTH_USERNAME" --arg pass "$AUTH_PASSWORD" \
--arg cat_name "$category_name" --arg sn "$service_name" '
{
"enable": true,
"removeCompletedDownloads": true,
"removeFailedDownloads": true,
"name": "qBittorrent",
"implementation": "QBittorrent",
"implementationName": "qBittorrent",
"configContract": "QBittorrentSettings",
"priority": 1,
"fields": [
{"name": "host", "value": $host},
{"name": "port", "value": 8081},
{"name": "useSsl", "value": false},
{"name": "urlBase", "value": ""},
{"name": "username", "value": $user},
{"name": "password", "value": $pass},
{"name": (if $sn == "Radarr" then "movieCategory" else "tvCategory" end), "value": $cat_name},
{"name": (if $sn == "Radarr" then "movieImportedCategory" else "tvImportedCategory" end), "value": ""},
{"name": (if $sn == "Radarr" then "recentMoviePriority" else "recentTvPriority" end), "value": 0},
{"name": (if $sn == "Radarr" then "olderMoviePriority" else "olderTvPriority" end), "value": 0},
{"name": "initialState", "value": 0},
{"name": "sequentialOrder", "value": false},
{"name": "firstAndLast", "value": false},
{"name": "contentLayout", "value": 0}
]
}
' 2>/dev/null || echo "")
if ! echo "$existing_impls" | grep -q "qBittorrent"; then
if [ -n "$qbit_payload" ]; then
local dc_result
dc_result=$(api_post "$port" "/api/v3/downloadclient" "$apikey" "$qbit_payload")
@ -264,7 +264,24 @@ configure_arr_service() {
critical_errors=$((critical_errors + 1))
fi
else
log_step "${service_name}: qBittorrent download client already exists"
local existing_dc dc_id updated_dc dc_put
existing_dc=$(echo "$download_clients" | jq '[.[] | select(.implementationName == "qBittorrent" or .implementation == "QBittorrent")][0]' 2>/dev/null || echo "")
dc_id=$(echo "$existing_dc" | jq -r '.id // empty' 2>/dev/null || echo "")
if [ -n "$dc_id" ] && [ -n "$qbit_payload" ]; then
updated_dc=$(echo "$qbit_payload" | jq --argjson id "$dc_id" '.id = $id' 2>/dev/null || echo "")
if [ -n "$updated_dc" ]; then
dc_put=$(api_put "$port" "/api/v3/downloadclient/${dc_id}" "$apikey" "$updated_dc")
if jq_json_has_key "$dc_put" "id"; then
log_step "${service_name}: updated qBittorrent download client (host: ${QBITTORRENT_HOST})"
else
log_step "${service_name}: qBittorrent download client already exists"
fi
else
log_step "${service_name}: qBittorrent download client already exists"
fi
else
log_step "${service_name}: qBittorrent download client already exists"
fi
fi
# 2b. Create qBittorrent category with correct save path
@ -530,12 +547,13 @@ prowlarr_set_app_profile_min_seeders() {
return 1
}
# Set minimumSeeders=0 on every indexer present in one *arr app.
# Call after Prowlarr fullSync has created the indexers (app profile alone is not enough).
# Indexer defaults: minSeeders=0, seed ratio 1.0, seed time 7d (override: ASSEMBLRR_SEED_RATIO / ASSEMBLRR_SEED_TIME_MINUTES).
arr_set_indexer_min_seeders() {
local service_name="$1"
local port="$2"
local apikey="$3"
local seed_ratio="${ASSEMBLRR_SEED_RATIO:-1.0}"
local seed_time="${ASSEMBLRR_SEED_TIME_MINUTES:-10080}"
[ -z "$apikey" ] && return 1
@ -551,9 +569,14 @@ arr_set_indexer_min_seeders() {
[ -z "$idx" ] && continue
idx_id=$(echo "$idx" | jq -r '.id // empty')
[ -z "$idx_id" ] && continue
idx_payload=$(echo "$idx" | jq '
idx_payload=$(echo "$idx" | jq \
--argjson ratio "$seed_ratio" \
--argjson stime "$seed_time" '
.fields = [.fields[] |
if .name == "minimumSeeders" then .value = 0 else . end
if .name == "minimumSeeders" then .value = 0
elif .name == "seedCriteria.seedRatio" then .value = $ratio
elif .name == "seedCriteria.seedTime" then .value = $stime
else . end
]
' 2>/dev/null || echo "")
[ -z "$idx_payload" ] && continue
@ -564,12 +587,93 @@ arr_set_indexer_min_seeders() {
done < <(echo "$indexers" | jq -c '.[]' 2>/dev/null || true)
if [ "$ok" -gt 0 ]; then
log_step "${service_name}: indexer minimumSeeders → 0 (${ok})"
log_step "${service_name}: indexer seedCriteria ratio=${seed_ratio} time=${seed_time}m, minSeeders=0 (${ok})"
return 0
fi
return 1
}
# On movie/series delete, run arr-purge-hook.sh (qB torrent + files).
add_arr_purge_hook() {
local app_name="$1"
local port="$2"
local apikey="$3"
[ -z "$apikey" ] && return 0
local notifications existing_id
notifications=$(api_get "$port" "/api/v3/notification" "$apikey")
existing_id=$(echo "$notifications" | jq -r \
'.[] | select(.implementation == "CustomScript" and .name == "assemblrr Media Purge") | .id // ""' \
2>/dev/null | head -1 || echo "")
if [ -f "${INSTALL_DIR:-}/scripts/arr-purge-hook.sh" ]; then
chmod +x "${INSTALL_DIR}/scripts/arr-purge-hook.sh" 2>/dev/null || true
fi
if [ -f "${INSTALL_DIR:-}/scripts/media-purge.sh" ]; then
chmod +x "${INSTALL_DIR}/scripts/media-purge.sh" 2>/dev/null || true
fi
local schema payload result
schema=$(api_get "$port" "/api/v3/notification/schema" "$apikey")
if [ "$app_name" = "Radarr" ]; then
payload=$(echo "$schema" | jq '
[.[] | select(.implementation == "CustomScript")][0] |
.fields = [.fields[] | if .name == "path" then .value = "/scripts/arr-purge-hook.sh" else . end] |
.onGrab = false | .onDownload = false | .onUpgrade = false |
.onRename = false | .onMovieAdded = false |
.onMovieDelete = true | .onMovieFileDelete = false |
.onMovieFileDeleteForUpgrade = false |
.onHealthIssue = false | .onHealthRestored = false |
.onApplicationUpdate = false | .onManualInteractionRequired = false |
.includeHealthWarnings = false |
.name = "assemblrr Media Purge" | .tags = []
' 2>/dev/null || echo "")
else
payload=$(echo "$schema" | jq '
[.[] | select(.implementation == "CustomScript")][0] |
.fields = [.fields[] | if .name == "path" then .value = "/scripts/arr-purge-hook.sh" else . end] |
.onGrab = false | .onDownload = false | .onUpgrade = false |
.onRename = false | .onSeriesAdd = false |
.onSeriesDelete = true | .onEpisodeFileDelete = false |
.onEpisodeFileDeleteForUpgrade = false |
.onHealthIssue = false | .onHealthRestored = false |
.onApplicationUpdate = false | .onManualInteractionRequired = false |
.includeHealthWarnings = false |
.name = "assemblrr Media Purge" | .tags = []
' 2>/dev/null || echo "")
fi
if [ -z "$payload" ]; then
log_step_fail "${app_name}: CustomScript schema not found for media purge"
return 1
fi
if [ -n "$existing_id" ]; then
payload=$(echo "$payload" | jq --argjson id "$existing_id" '.id = $id' 2>/dev/null || echo "$payload")
result=$(api_put "$port" "/api/v3/notification/${existing_id}" "$apikey" "$payload")
if jq_json_has_key "$result" "id"; then
log_step "${app_name}: updated Media Purge hook"
return 0
fi
fi
result=$(api_post_force "$port" "/api/v3/notification" "$apikey" "$payload")
if jq_json_has_key "$result" "id"; then
log_step "${app_name}: added Media Purge hook"
return 0
fi
local err_msg
err_msg=$(echo "$result" | jq -r 'if type == "array" then .[0].errorMessage // empty else .errorMessage // empty end' 2>/dev/null || echo "")
if [ -n "$err_msg" ]; then
log_step_fail "${app_name}: failed to add Media Purge hook (${err_msg})"
else
log_step_fail "${app_name}: failed to add Media Purge hook"
fi
return 1
}
# Wait until Radarr has at least one indexer from Prowlarr fullSync (max ~30s).
# Sonarr is not a gate — movie-only indexers never appear there.
wait_for_arr_indexer_sync() {

View file

@ -44,6 +44,9 @@ bin/setup.sh|setup.sh
bin/docker-install.sh|docker-install.sh
scripts/jellyfin-refresh.sh|scripts/jellyfin-refresh.sh
scripts/vpn-watchdog.sh|scripts/vpn-watchdog.sh
scripts/media-purge.sh|scripts/media-purge.sh
scripts/arr-purge-hook.sh|scripts/arr-purge-hook.sh
scripts/media-purge-watch.sh|scripts/media-purge-watch.sh
docs/api-reference.md|docs/api-reference.md
docs/trash-guides.md|docs/trash-guides.md
docs/uninstall.md|docs/uninstall.md

View file

@ -50,6 +50,9 @@ list_service_catalog() {
"portainer|Portainer|9000|/|ui" \
"recyclarr|Recyclarr|||internal"
printf '%s\n' \
"media-purge-watch|Media purge watch|||internal"
if [ "${VPN_ENABLED:-n}" = "y" ]; then
printf '%s\n' \
"gluetun|Gluetun (VPN)|||internal" \

View file

@ -172,7 +172,6 @@ run_migrations() {
local check_only="${3:-0}"
local mig_dir="$source_root/migrations"
local f
UPGRADE_NEED_CONFIG_SYNC="${UPGRADE_NEED_CONFIG_SYNC:-0}"
if [ ! -d "$mig_dir" ]; then
log_info "No migrations directory in source"
@ -359,7 +358,6 @@ upgrade_app() {
cp -a "$UPGRADE_SOURCE_ROOT/migrations/." "$INSTALL_DIR/migrations/" 2>/dev/null || true
fi
UPGRADE_NEED_CONFIG_SYNC=0
log_info "Running migrations..."
run_migrations "$UPGRADE_SOURCE_ROOT" "$INSTALL_DIR" 0
@ -376,17 +374,15 @@ upgrade_app() {
log_error "docker compose up failed. Restore with: ${APP_CLI_NAME:-assemblrr} restore <backup.tar.gz>"
fi
# Always run config sync after upgrade when Bazarr (or other wire) may need it;
# config sync is idempotent for existing services.
if [ "${UPGRADE_NEED_CONFIG_SYNC:-0}" = "1" ] || [ ! -f "$INSTALL_DIR/secrets/bazarr_api_key.txt" ]; then
if [ -f "$INSTALL_DIR/config.sh" ]; then
log_info "Running config sync (service wiring)..."
if bash "$INSTALL_DIR/config.sh"; then
log_success "Config sync completed"
else
log_warning "Config sync reported failures — fix then: ${APP_CLI_NAME:-assemblrr} config sync"
fi
if [ -f "$INSTALL_DIR/config.sh" ]; then
log_info "Wiring services..."
if bash "$INSTALL_DIR/config.sh"; then
log_success "Service wiring completed"
else
log_warning "Service wiring reported failures. Fix issues, then re-run: ${APP_CLI_NAME:-assemblrr} upgrade"
fi
else
log_warning "config.sh missing after upgrade — cannot wire services"
fi
cleanup_upgrade_source

View file

@ -42,12 +42,8 @@ migration_should_skip() {
return 1
}
# Return 0 on success. May set UPGRADE_NEED_CONFIG_SYNC=1
migration_apply() {
local install_dir="${1:-}"
mkdir -p "$install_dir/config/bazarr"
# Managed base.yaml already includes the service after apply_managed_files.
# Flag post-upgrade wiring.
UPGRADE_NEED_CONFIG_SYNC=1
return 0
}

30
scripts/arr-purge-hook.sh Executable file
View file

@ -0,0 +1,30 @@
#!/bin/bash
# Radarr/Sonarr Custom Script → media-purge (qB cleanup on delete).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PURGE="${SCRIPT_DIR}/media-purge.sh"
[ -x "$PURGE" ] || chmod +x "$PURGE" 2>/dev/null || true
export API_HOST="${API_HOST:-127.0.0.1}"
export QBITTORRENT_URL="${QBITTORRENT_URL:-}"
export AUTH_USERNAME_FILE="${AUTH_USERNAME_FILE:-/run/secrets/auth_username.txt}"
export AUTH_PASSWORD_FILE="${AUTH_PASSWORD_FILE:-/run/secrets/auth_password.txt}"
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"
elif curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" "http://qbittorrent:8081/" 2>/dev/null | grep -qE '^[2345]'; then
export QBITTORRENT_URL="http://qbittorrent:8081"
fi
fi
if [ -n "${radarr_eventtype:-}" ]; then
exec "$PURGE" --radarr-event
fi
if [ -n "${sonarr_eventtype:-}" ]; then
exec "$PURGE" --sonarr-event
fi
echo "arr-purge-hook: no event env" >&2
exit 0

91
scripts/media-purge-watch.sh Executable file
View file

@ -0,0 +1,91 @@
#!/bin/bash
# inotify on /data/media → media-purge when library files are deleted.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PURGE="${SCRIPT_DIR}/media-purge.sh"
MEDIA_ROOT="${MEDIA_ROOT:-/data}"
WATCH_ROOT="${WATCH_ROOT:-${MEDIA_ROOT}/media}"
DEBOUNCE_SEC="${DEBOUNCE_SEC:-2}"
export API_HOST="${API_HOST:-127.0.0.1}"
export RADARR_URL="${RADARR_URL:-http://radarr:7878}"
export SONARR_URL="${SONARR_URL:-http://sonarr:8989}"
export CONFIG_DIR="${CONFIG_DIR:-/config}"
export AUTH_USERNAME_FILE="${AUTH_USERNAME_FILE:-/run/secrets/auth_username.txt}"
export AUTH_PASSWORD_FILE="${AUTH_PASSWORD_FILE:-/run/secrets/auth_password.txt}"
log() { echo "media-purge-watch: $*" >&2; }
[ -x "$PURGE" ] || chmod +x "$PURGE" 2>/dev/null || true
if ! command -v inotifywait >/dev/null 2>&1; then
apk add --no-cache inotify-tools curl jq bash >/dev/null
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"
elif curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" "http://qbittorrent:8081/" 2>/dev/null | grep -qE '^[2345]'; then
export QBITTORRENT_URL="http://qbittorrent:8081"
else
export QBITTORRENT_URL="http://gluetun:8081"
fi
fi
log "watching ${WATCH_ROOT} (qB=${QBITTORRENT_URL})"
library_folder_for() {
local p="$1"
case "$p" in
*/media/movies/*) echo "$p" | sed -E 's|(.*/media/movies/[^/]+).*|\1|' ;;
*/media/tv/*) echo "$p" | sed -E 's|(.*/media/tv/[^/]+).*|\1|' ;;
*) echo "" ;;
esac
}
LAST_PURGE_PATH=""
LAST_PURGE_TS=0
handle_event() {
local path="$1"
local folder
folder=$(library_folder_for "$path")
[ -n "$folder" ] || return 0
local now
now=$(date +%s)
if [ "$folder" = "$LAST_PURGE_PATH" ] && [ $((now - LAST_PURGE_TS)) -lt 15 ]; then
return 0
fi
sleep "$DEBOUNCE_SEC"
if [ -e "$folder" ]; then
if find "$folder" -type f \( -name '*.mkv' -o -name '*.mp4' -o -name '*.avi' -o -name '*.m4v' -o -name '*.ts' \) 2>/dev/null | grep -q .; then
return 0
fi
fi
LAST_PURGE_PATH="$folder"
LAST_PURGE_TS=$now
local arr_path
case "$folder" in
/data/*) arr_path="$folder" ;;
*) arr_path=$(echo "$folder" | sed -E 's|.*/media/|/data/media/|') ;;
esac
log "purge $arr_path"
"$PURGE" --path "$arr_path" || log "purge failed for $arr_path"
}
mkdir -p "$WATCH_ROOT/movies" "$WATCH_ROOT/tv" 2>/dev/null || true
inotifywait -m -r \
-e delete,delete_self,moved_from \
--format '%e|%w|%f' \
"$WATCH_ROOT" 2>/dev/null | while IFS='|' read -r events watch_dir filename; do
full="${watch_dir}${filename}"
case "$full" in
*'/.nfs'*|*/.tmp*|*/tmp/*) continue ;;
esac
handle_event "$full" &
done

395
scripts/media-purge.sh Executable file
View file

@ -0,0 +1,395 @@
#!/bin/bash
# Remove a title from *arr, library files, and qBittorrent.
# Usage: media-purge.sh --movie-id N | --series-id N | --path P | --tmdb ID | --tvdb ID
# media-purge.sh --radarr-event | --sonarr-event | --qb-only --path P
set -euo pipefail
MEDIA_ROOT="${MEDIA_ROOT:-/data}"
API_HOST="${API_HOST:-127.0.0.1}"
RADARR_URL="${RADARR_URL:-http://${API_HOST}:7878}"
SONARR_URL="${SONARR_URL:-http://${API_HOST}:8989}"
CONFIG_DIR="${CONFIG_DIR:-}"
DRY_RUN="${DRY_RUN:-0}"
QB_ONLY=0
MODE=""
MOVIE_ID=""
SERIES_ID=""
PATH_ARG=""
TMDB_ID=""
TVDB_ID=""
log() { echo "media-purge: $*" >&2; }
die() { log "ERROR: $*"; exit 1; }
read_secret_file() {
local f="$1"
[ -f "$f" ] || return 1
tr -d '\r\n' <"$f"
}
basename_of() {
local p="$1"
p="${p%/}"
echo "${p##*/}"
}
discover_api_key() {
local svc="$1"
local env_key="${2:-}"
if [ -n "${env_key}" ]; then
echo "$env_key"
return 0
fi
local f
for f in \
"${CONFIG_DIR:+$CONFIG_DIR/$svc/config.xml}" \
"/config/$svc/config.xml" \
"${INSTALL_DIR:+$INSTALL_DIR/config/$svc/config.xml}"; do
[ -n "${f:-}" ] && [ -f "$f" ] || continue
local k
k=$(sed -n 's/.*<ApiKey>\(.*\)<\/ApiKey>.*/\1/p' "$f" 2>/dev/null | head -1)
if [ -n "$k" ]; then
echo "$k"
return 0
fi
done
return 1
}
qbit_login() {
local user pass jar
jar="${1:-/tmp/media-purge-qb.cookies}"
user="${AUTH_USERNAME:-}"
pass="${AUTH_PASSWORD:-}"
[ -z "$user" ] && user=$(read_secret_file "${AUTH_USERNAME_FILE:-/run/secrets/auth_username.txt}" 2>/dev/null || true)
[ -z "$pass" ] && pass=$(read_secret_file "${AUTH_PASSWORD_FILE:-/run/secrets/auth_password.txt}" 2>/dev/null || true)
[ -n "$user" ] && [ -n "$pass" ] || die "qBittorrent credentials missing"
if [ -n "${QBITTORRENT_URL:-}" ]; then
QBIT_BASE="$QBITTORRENT_URL"
else
QBIT_BASE=""
local try
for try in "http://gluetun:8081" "http://qbittorrent:8081" "http://${API_HOST}:8081" "http://127.0.0.1:8081"; do
if curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" "${try}/" 2>/dev/null | grep -qE '^[2345]'; then
QBIT_BASE="$try"
break
fi
done
[ -n "$QBIT_BASE" ] || QBIT_BASE="http://${API_HOST}:8081"
fi
QBIT_JAR="$jar"
curl -s --connect-timeout 8 -c "$QBIT_JAR" \
-H "Referer: ${QBIT_BASE}/" \
-d "username=${user}&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
die "qBittorrent login failed (${QBIT_BASE})"
fi
log "qB session ok (${QBIT_BASE})"
}
qbit_delete_hashes() {
local hashes=("$@")
[ "${#hashes[@]}" -gt 0 ] || return 0
qbit_login
local csv="" h
for h in "${hashes[@]}"; do
h=$(echo "$h" | tr 'A-F' 'a-f' | tr -d '[:space:]')
[ -n "$h" ] || continue
[ -n "$csv" ] && csv="${csv}|${h}" || csv="$h"
done
[ -n "$csv" ] || return 0
log "qB: deleting hashes with files: $csv"
if [ "$DRY_RUN" = "1" ]; then
log "DRY_RUN: skip qB delete"
return 0
fi
curl -s --connect-timeout 15 -b "$QBIT_JAR" -H "Referer: ${QBIT_BASE}/" \
-d "hashes=${csv}&deleteFiles=true" \
"${QBIT_BASE}/api/v2/torrents/delete" >/dev/null || true
}
qbit_delete_by_tokens() {
local tokens=("$@")
local cleaned=() t
for t in "${tokens[@]}"; do
[ -n "$t" ] || continue
t=$(echo "$t" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
case "$t" in
media|movies|tv|torrents|data|incomplete|/data|/data/*movies|/data/*tv) continue ;;
esac
local b
b=$(basename_of "$t")
if [ "${#b}" -ge 10 ]; then
cleaned+=("$b")
fi
done
if [ "${#cleaned[@]}" -eq 0 ]; then
log "qB: no match tokens"
return 0
fi
qbit_login
local info
info=$(curl -s --connect-timeout 10 -b "$QBIT_JAR" -H "Referer: ${QBIT_BASE}/" \
"${QBIT_BASE}/api/v2/torrents/info" 2>/dev/null || echo "[]")
[ -n "$info" ] && [ "$info" != "[]" ] || { log "qB: no torrents"; return 0; }
local tokens_json hashes
tokens_json=$(printf '%s\n' "${cleaned[@]}" | jq -R . | jq -s -c .)
log "qB: tokens: $(echo "$tokens_json" | jq -r 'join(" | ")')"
hashes=$(echo "$info" | jq -r --argjson tokens "$tokens_json" '
def norm: ascii_downcase;
def hit($text):
($text | norm | gsub("\\\\"; "/")) as $t
| any(
$tokens[] | select(length >= 10);
(. | norm) as $tok
| (
($t | contains("/" + $tok))
or ($t | startswith($tok))
or ($t | contains($tok + " ["))
or ($t | contains($tok + "/"))
)
);
.[]
| select(hit(.name // "") or hit(.content_path // ""))
| .hash
' 2>/dev/null || true)
if [ -z "${hashes// }" ]; then
log "qB: no torrents matched tokens"
return 0
fi
# shellcheck disable=SC2086
qbit_delete_hashes $hashes
}
arr_get() {
local base="$1" key="$2" path="$3"
curl -s --connect-timeout 10 -H "X-Api-Key: ${key}" "${base}${path}" 2>/dev/null || true
}
arr_history_hashes() {
local base="$1" key="$2" kind="$3" id="$4"
local endpoint hist
if [ "$kind" = "movie" ]; then
endpoint="/api/v3/history/movie?movieId=${id}"
hist=$(arr_get "$base" "$key" "$endpoint")
echo "$hist" | jq -r '
.. | objects
| .downloadId? // .data.torrentInfoHash? // empty
' 2>/dev/null | tr 'A-F' 'a-f' | sort -u
else
endpoint="/api/v3/history?pageSize=100&seriesId=${id}"
hist=$(arr_get "$base" "$key" "$endpoint")
echo "$hist" | jq -r '
.records[]? | .downloadId // .data.torrentInfoHash // empty
' 2>/dev/null | tr 'A-F' 'a-f' | sort -u
fi
}
arr_delete_movie() {
local key="$1" id="$2"
log "Radarr: DELETE movie id=${id} (deleteFiles=true)"
if [ "$DRY_RUN" = "1" ]; then log "DRY_RUN"; return 0; fi
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 15 -X DELETE \
-H "X-Api-Key: ${key}" \
"${RADARR_URL}/api/v3/movie/${id}?deleteFiles=true&addImportExclusion=false" 2>/dev/null || echo "000")
log "Radarr: HTTP ${code}"
}
arr_delete_series() {
local key="$1" id="$2"
log "Sonarr: DELETE series id=${id} (deleteFiles=true)"
if [ "$DRY_RUN" = "1" ]; then log "DRY_RUN"; return 0; fi
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 15 -X DELETE \
-H "X-Api-Key: ${key}" \
"${SONARR_URL}/api/v3/series/${id}?deleteFiles=true&addImportListExclusion=false" 2>/dev/null || echo "000")
log "Sonarr: HTTP ${code}"
}
purge_qb_for_movie() {
local key="$1" id="$2" title="$3" path="$4"
local hashes hlist=()
while IFS= read -r h; do
[ -n "$h" ] && hlist+=("$h")
done < <(arr_history_hashes "$RADARR_URL" "$key" movie "$id")
if [ "${#hlist[@]}" -gt 0 ]; then
log "Radarr history hashes for movie ${id}: ${hlist[*]}"
qbit_delete_hashes "${hlist[@]}"
else
log "no history hashes for movie ${id}; token fallback"
qbit_delete_by_tokens "$title" "$path" "$(basename_of "$path")"
fi
}
purge_qb_for_series() {
local key="$1" id="$2" title="$3" path="$4"
local hlist=()
while IFS= read -r h; do
[ -n "$h" ] && hlist+=("$h")
done < <(arr_history_hashes "$SONARR_URL" "$key" series "$id")
if [ "${#hlist[@]}" -gt 0 ]; then
log "Sonarr history hashes for series ${id}: ${hlist[*]}"
qbit_delete_hashes "${hlist[@]}"
else
log "no history hashes for series ${id}; token fallback"
qbit_delete_by_tokens "$title" "$path" "$(basename_of "$path")"
fi
}
# --- args ---
while [ $# -gt 0 ]; do
case "$1" in
--movie-id) MOVIE_ID="$2"; MODE=movie; shift 2 ;;
--series-id) SERIES_ID="$2"; MODE=series; shift 2 ;;
--path) PATH_ARG="$2"; MODE="${MODE:-path}"; shift 2 ;;
--tmdb) TMDB_ID="$2"; MODE=tmdb; shift 2 ;;
--tvdb) TVDB_ID="$2"; MODE=tvdb; shift 2 ;;
--radarr-event) MODE=radarr-event; shift ;;
--sonarr-event) MODE=sonarr-event; shift ;;
--qb-only) QB_ONLY=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
done
RADARR_API_KEY=$(discover_api_key radarr "${RADARR_API_KEY:-}") || RADARR_API_KEY=""
SONARR_API_KEY=$(discover_api_key sonarr "${SONARR_API_KEY:-}") || SONARR_API_KEY=""
case "$MODE" in
radarr-event)
et="${radarr_eventtype:-}"
log "radarr event type=${et}"
case "$et" in
Test) log "Test ok"; exit 0 ;;
MovieDelete|MovieFileDelete) ;;
*) log "ignore ${et}"; exit 0 ;;
esac
mid="${radarr_movie_id:-}"
title="${radarr_movie_title:-}"
path="${radarr_movie_path:-}"
if [ -n "$mid" ] && [ -n "$RADARR_API_KEY" ]; then
purge_qb_for_movie "$RADARR_API_KEY" "$mid" "$title" "$path"
else
qbit_delete_by_tokens "$title" "$path" "$(basename_of "$path")" \
"${radarr_moviefile_path:-}" "$(basename_of "${radarr_moviefile_path:-}")"
fi
exit 0
;;
sonarr-event)
et="${sonarr_eventtype:-}"
log "sonarr event type=${et}"
case "$et" in
Test) log "Test ok"; exit 0 ;;
SeriesDelete|EpisodeFileDelete) ;;
*) log "ignore ${et}"; exit 0 ;;
esac
sid="${sonarr_series_id:-}"
title="${sonarr_series_title:-}"
path="${sonarr_series_path:-}"
if [ -n "$sid" ] && [ -n "$SONARR_API_KEY" ]; then
purge_qb_for_series "$SONARR_API_KEY" "$sid" "$title" "$path"
else
qbit_delete_by_tokens "$title" "$path" "$(basename_of "$path")"
fi
exit 0
;;
movie)
[ -n "$MOVIE_ID" ] || die "--movie-id required"
[ -n "$RADARR_API_KEY" ] || die "Radarr API key missing"
movie=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie/${MOVIE_ID}")
title=$(echo "$movie" | jq -r '.title // empty')
path=$(echo "$movie" | jq -r '.path // empty')
# qB first (history still available), then *arr delete
purge_qb_for_movie "$RADARR_API_KEY" "$MOVIE_ID" "$title" "$path"
if [ "$QB_ONLY" != "1" ]; then
arr_delete_movie "$RADARR_API_KEY" "$MOVIE_ID"
fi
;;
series)
[ -n "$SERIES_ID" ] || die "--series-id required"
[ -n "$SONARR_API_KEY" ] || die "Sonarr API key missing"
series=$(arr_get "$SONARR_URL" "$SONARR_API_KEY" "/api/v3/series/${SERIES_ID}")
title=$(echo "$series" | jq -r '.title // empty')
path=$(echo "$series" | jq -r '.path // empty')
purge_qb_for_series "$SONARR_API_KEY" "$SERIES_ID" "$title" "$path"
if [ "$QB_ONLY" != "1" ]; then
arr_delete_series "$SONARR_API_KEY" "$SERIES_ID"
fi
;;
path)
[ -n "$PATH_ARG" ] || die "--path required"
path="$PATH_ARG"
base=$(basename_of "$path")
log "purge by path=${path}"
if [ "$QB_ONLY" != "1" ] && [ -n "$RADARR_API_KEY" ]; then
movies=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie")
mid=$(echo "$movies" | jq -r --arg p "$path" --arg b "$base" '
.[] | select(
(.path // "") == $p
or ((.path // "") | endswith("/" + $b))
or ((.path // "") | endswith($b))
) | .id' 2>/dev/null | head -1)
if [ -n "$mid" ]; then
movie=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie/${mid}")
title=$(echo "$movie" | jq -r '.title // empty')
mpath=$(echo "$movie" | jq -r '.path // empty')
purge_qb_for_movie "$RADARR_API_KEY" "$mid" "$title" "$mpath"
arr_delete_movie "$RADARR_API_KEY" "$mid"
exit 0
fi
fi
if [ "$QB_ONLY" != "1" ] && [ -n "$SONARR_API_KEY" ]; then
series_json=$(arr_get "$SONARR_URL" "$SONARR_API_KEY" "/api/v3/series")
sid=$(echo "$series_json" | jq -r --arg p "$path" --arg b "$base" '
.[] | select(
(.path // "") == $p
or ((.path // "") | endswith("/" + $b))
or ((.path // "") | endswith($b))
) | .id' 2>/dev/null | head -1)
if [ -n "$sid" ]; then
series=$(arr_get "$SONARR_URL" "$SONARR_API_KEY" "/api/v3/series/${sid}")
title=$(echo "$series" | jq -r '.title // empty')
spath=$(echo "$series" | jq -r '.path // empty')
purge_qb_for_series "$SONARR_API_KEY" "$sid" "$title" "$spath"
arr_delete_series "$SONARR_API_KEY" "$sid"
exit 0
fi
fi
log "no *arr row; token-only qB cleanup"
qbit_delete_by_tokens "$base" "$path"
;;
tmdb)
[ -n "$TMDB_ID" ] || die "--tmdb required"
[ -n "$RADARR_API_KEY" ] || die "Radarr API key missing"
movies=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie")
mid=$(echo "$movies" | jq -r --argjson t "$TMDB_ID" '.[] | select(.tmdbId == $t) | .id' | head -1)
[ -n "$mid" ] || die "no Radarr movie tmdbId=${TMDB_ID}"
exec "$0" --movie-id "$mid"
;;
tvdb)
[ -n "$TVDB_ID" ] || die "--tvdb required"
[ -n "$SONARR_API_KEY" ] || die "Sonarr API key missing"
series_json=$(arr_get "$SONARR_URL" "$SONARR_API_KEY" "/api/v3/series")
sid=$(echo "$series_json" | jq -r --argjson t "$TVDB_ID" '.[] | select(.tvdbId == $t) | .id' | head -1)
[ -n "$sid" ] || die "no Sonarr series tvdbId=${TVDB_ID}"
exec "$0" --series-id "$sid"
;;
*)
die "specify --movie-id, --series-id, --path, --tmdb, --tvdb, --radarr-event, or --sonarr-event"
;;
esac
log "done"

View file

@ -11,10 +11,34 @@ make test
## Live integration
Hits a running qBittorrent (stalls connections, runs `vpn-watchdog`). Opt-in only:
Opt-in only — these mutate a real install:
```bash
ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/run.sh integration
```
Install dir: `ASSEMBLRR_DIR`, `ASSEMBLRR_CONFIG`, then `~/assemblrr`.
Or one suite:
```bash
ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/integration/watchdog_stall.sh
ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/integration/media_purge_e2e.sh
```
Install dir resolution: `ASSEMBLRR_DIR`, `ASSEMBLRR_CONFIG`, then `~/assemblrr`.
| Script | What it does |
|--------|----------------|
| `watchdog_stall.sh` | Stalls qBittorrent connections; checks `vpn-watchdog` |
| `media_purge_e2e.sh` | Full delete cascade for Big Buck Bunny (TMDB 10378) via **Radarr**, **Seerr**, and **Jellyfin** APIs |
### media_purge_e2e
Requires a running stack with purge wiring (`media-purge-watch` up, Radarr “Media Purge” notification on).
1. Creates a synthetic hardlinked title under `media/` + `torrents/` and a local qB torrent (no indexer grab).
2. **Radarr** `DELETE /api/v3/movie/:id?deleteFiles=true` → CustomScript → qB cleanup.
3. **Seerr** `DELETE /api/v1/media/:id/file` → Radarr delete → same cascade.
4. **Jellyfin** `DELETE /Items/:id` → filesystem delete → `media-purge-watch` → full purge.
5. Asserts Radarr row, `media/`, `torrents/`, and qB torrent are all gone (other titles untouched).
Optional env: `API_HOST`, `QBIT_HOST` (defaults `127.0.0.1` / `127.0.0.1:8081`).

View file

@ -0,0 +1,386 @@
#!/bin/bash
# Live e2e: media purge via Seerr API, Jellyfin API, and Radarr API.
# Mutates a real assemblrr install — requires ASSEMBLRR_ALLOW_LIVE_TEST=1.
#
# Uses TMDB 10378 (Big Buck Bunny) with a synthetic hardlinked file + local
# .torrent so no indexer grab is required. Does not delete other library titles.
#
# What it proves:
# 1) Seerr DELETE /api/v1/media/:id/file → Radarr + media/ + torrents/ + qB
# 2) Jellyfin DELETE /Items/:id → inotify watch → full purge
# 3) Radarr DELETE /api/v3/movie/:id → CustomScript hook → qB cleanup
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../helpers.sh"
TMDB_ID=10378
TITLE="Big Buck Bunny"
TITLE_YEAR="Big Buck Bunny (2008)"
TORRENT_DIR_NAME="Big Buck Bunny (2008) [e2e]"
COOKIE_JAR="${TMPDIR:-/tmp}/assemblrr_media_purge_e2e_qb.cookies"
FAILED=0
INSTALL_DIR=""
MEDIA_DIR=""
RADARR_KEY=""
SEERR_KEY=""
JF_KEY=""
AUTH_USER=""
AUTH_PASS=""
die() { echo "ERROR: $*" >&2; exit 1; }
pass() { echo " PASS: $*"; }
fail() { echo " FAIL: $*"; FAILED=$((FAILED + 1)); }
require_live_opt_in() {
if [ "${ASSEMBLRR_ALLOW_LIVE_TEST:-}" != "1" ]; then
cat >&2 <<'EOF'
Refusing to run live media-purge e2e.
This adds/deletes a Big Buck Bunny test title on your real install
(Radarr, Seerr, Jellyfin, qBittorrent, disk). Re-run with:
ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/integration/media_purge_e2e.sh
Optional:
ASSEMBLRR_DIR=/path/to/install
ASSEMBLRR_CONFIG=/path/to/.assemblrr-config
QBIT_HOST=127.0.0.1:8081
API_HOST=127.0.0.1
EOF
exit 2
fi
}
api_host() { echo "${API_HOST:-127.0.0.1}"; }
read_key() {
local f="$1"
[ -f "$f" ] || return 1
tr -d '\r\n' <"$f"
}
radarr_xml_key() {
sed -n 's/.*<ApiKey>\(.*\)<\/ApiKey>.*/\1/p' "$INSTALL_DIR/config/radarr/config.xml" 2>/dev/null | head -1
}
qb_login() {
qbit_login "$COOKIE_JAR" "$AUTH_USER" "$AUTH_PASS" \
|| die "qBittorrent login failed at ${QBIT_HOST}"
}
qb_names() {
qbit_api "$COOKIE_JAR" "${QBIT_API}/torrents/info" | jq -r '.[].name // empty' 2>/dev/null || true
}
qb_has_bbb() {
qb_names | grep -qi 'Buck Bunny' && return 0
return 1
}
qb_delete_bbb() {
local hashes
hashes=$(qbit_api "$COOKIE_JAR" "${QBIT_API}/torrents/info" \
| jq -r '.[] | select(.name | test("Buck Bunny"; "i")) | .hash' 2>/dev/null || true)
[ -n "$hashes" ] || return 0
local h csv=""
for h in $hashes; do
[ -n "$csv" ] && csv="${csv}|${h}" || csv="$h"
done
qbit_api "$COOKIE_JAR" -d "hashes=${csv}&deleteFiles=true" "${QBIT_API}/torrents/delete" >/dev/null 2>&1 || true
}
radarr_bbb_ids() {
curl -s "http://$(api_host):7878/api/v3/movie?apikey=${RADARR_KEY}" \
| jq -r --argjson t "$TMDB_ID" '.[] | select(.tmdbId == $t) | .id' 2>/dev/null || true
}
radarr_bbb_count() {
radarr_bbb_ids | grep -c . || true
}
cleanup_bbb() {
local id mid
for id in $(radarr_bbb_ids); do
curl -s -X DELETE \
"http://$(api_host):7878/api/v3/movie/${id}?deleteFiles=true&addImportExclusion=false&apikey=${RADARR_KEY}" \
>/dev/null 2>&1 || true
done
if [ -n "${SEERR_KEY:-}" ]; then
for mid in $(curl -s "http://$(api_host):5055/api/v1/media?take=100" -H "X-Api-Key: ${SEERR_KEY}" \
| jq -r --argjson t "$TMDB_ID" '.results[]? | select(.tmdbId == $t) | .id' 2>/dev/null || true); do
curl -s -X DELETE "http://$(api_host):5055/api/v1/media/${mid}" \
-H "X-Api-Key: ${SEERR_KEY}" >/dev/null 2>&1 || true
curl -s -X DELETE "http://$(api_host):5055/api/v1/media/${mid}/file?is4k=false" \
-H "X-Api-Key: ${SEERR_KEY}" >/dev/null 2>&1 || true
done
fi
rm -rf "${MEDIA_DIR}/torrents/movies/${TORRENT_DIR_NAME}" \
"${MEDIA_DIR}/media/movies/${TITLE_YEAR}" 2>/dev/null || true
qb_delete_bbb
}
make_torrent() {
local root="$1" name="$2" out="$3"
python3 - "$root" "$name" "$out" <<'PY'
import hashlib, sys
from pathlib import Path
root, name, out = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
files = sorted(p for p in root.rglob("*") if p.is_file())
pl = 262144
blob = b"".join(p.read_bytes() for p in files)
pieces = b"".join(hashlib.sha1(blob[i : i + pl]).digest() for i in range(0, len(blob), pl))
fl = [{"length": p.stat().st_size, "path": list(p.relative_to(root).parts)} for p in files]
def be(x):
if isinstance(x, int):
return f"i{x}e".encode()
if isinstance(x, bytes):
return f"{len(x)}:".encode() + x
if isinstance(x, str):
b = x.encode()
return f"{len(b)}:".encode() + b
if isinstance(x, list):
return b"l" + b"".join(be(i) for i in x) + b"e"
if isinstance(x, dict):
o = b"d"
for k in sorted(x):
o += be(k) + be(x[k])
return o + b"e"
raise TypeError(type(x))
out.write_bytes(
be(
{
"announce": "udp://tracker.opentrackr.org:1337/announce",
"info": {"name": name, "piece length": pl, "pieces": pieces, "files": fl},
}
)
)
print(out)
PY
}
setup_title() {
cleanup_bbb
local lookup payload movie_id
lookup=$(curl -sG "http://$(api_host):7878/api/v3/movie/lookup" \
--data-urlencode "term=tmdb:${TMDB_ID}" -H "X-Api-Key: ${RADARR_KEY}")
payload=$(echo "$lookup" | jq -c --argjson t "$TMDB_ID" '
[.[] | select(.tmdbId == $t)][0]
+ {
qualityProfileId: 1,
rootFolderPath: "/data/media/movies",
monitored: true,
minimumAvailability: "released",
addOptions: { searchForMovie: false }
}
| del(.id, .folderName, .remotePoster, .images)
')
movie_id=$(curl -s -X POST "http://$(api_host):7878/api/v3/movie?apikey=${RADARR_KEY}" \
-H "Content-Type: application/json" -d "$payload" | jq -r '.id // empty')
[ -n "$movie_id" ] || die "failed to add Radarr movie tmdb=${TMDB_ID}"
mkdir -p "${MEDIA_DIR}/torrents/movies/${TORRENT_DIR_NAME}" \
"${MEDIA_DIR}/media/movies/${TITLE_YEAR}"
dd if=/dev/urandom of="${MEDIA_DIR}/torrents/movies/${TORRENT_DIR_NAME}/video.mkv" \
bs=1M count=2 status=none
ln "${MEDIA_DIR}/torrents/movies/${TORRENT_DIR_NAME}/video.mkv" \
"${MEDIA_DIR}/media/movies/${TITLE_YEAR}/${TITLE_YEAR} WEBDL-480p.mkv"
local torrent_path="${TMPDIR:-/tmp}/assemblrr-e2e-bbb.torrent"
make_torrent "${MEDIA_DIR}/torrents/movies/${TORRENT_DIR_NAME}" "$TORRENT_DIR_NAME" "$torrent_path" >/dev/null
qbit_api "$COOKIE_JAR" -H "Referer: http://${QBIT_HOST}" \
-F "torrents=@${torrent_path}" \
-F "savepath=/data/torrents/movies" \
-F "category=movies" \
-F "skip_checking=true" \
"${QBIT_API}/torrents/add" >/dev/null
curl -s -X POST "http://$(api_host):7878/api/v3/command?apikey=${RADARR_KEY}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"RescanMovie\",\"movieId\":${movie_id}}" >/dev/null
local i has_file=false
for i in $(seq 1 20); do
has_file=$(curl -s "http://$(api_host):7878/api/v3/movie/${movie_id}?apikey=${RADARR_KEY}" \
| jq -r '.hasFile // false')
[ "$has_file" = "true" ] && break
sleep 1
done
[ "$has_file" = "true" ] || die "Radarr did not import test file (movie id ${movie_id})"
qb_has_bbb || die "qBittorrent missing test torrent"
echo "$movie_id"
}
assert_fully_gone() {
local label="$1"
local radarr_n media_exists torr_exists qb_bbb
sleep 3
radarr_n=$(radarr_bbb_count)
media_exists=0
torr_exists=0
[ -e "${MEDIA_DIR}/media/movies/${TITLE_YEAR}" ] && media_exists=1
[ -e "${MEDIA_DIR}/torrents/movies/${TORRENT_DIR_NAME}" ] && torr_exists=1
if qb_has_bbb; then qb_bbb=1; else qb_bbb=0; fi
if [ "$radarr_n" = "0" ] && [ "$media_exists" = "0" ] && [ "$torr_exists" = "0" ] && [ "$qb_bbb" = "0" ]; then
pass "$label (radarr+media+torrents+qB clean)"
return 0
fi
fail "$label (radarr=${radarr_n} media=${media_exists} torrents=${torr_exists} qb_bbb=${qb_bbb})"
return 1
}
# --- Seerr: DELETE /api/v1/media/:id/file ---
test_seerr_delete_file() {
echo ""
echo "=== Seerr DELETE /api/v1/media/:id/file ==="
local movie_id seerr_media_id code
movie_id=$(setup_title)
echo " Radarr movie id=${movie_id}"
curl -s -X POST "http://$(api_host):5055/api/v1/request" \
-H "X-Api-Key: ${SEERR_KEY}" -H "Content-Type: application/json" \
-d "{\"mediaType\":\"movie\",\"mediaId\":${TMDB_ID},\"is4k\":false}" >/dev/null || true
seerr_media_id=$(curl -s "http://$(api_host):5055/api/v1/media?take=100" -H "X-Api-Key: ${SEERR_KEY}" \
| jq -r --argjson t "$TMDB_ID" '.results[]? | select(.tmdbId == $t) | .id' | head -1)
if [ -z "$seerr_media_id" ]; then
seerr_media_id=$(curl -s "http://$(api_host):5055/api/v1/movie/${TMDB_ID}" -H "X-Api-Key: ${SEERR_KEY}" \
| jq -r '.mediaInfo.id // empty')
fi
[ -n "$seerr_media_id" ] || { fail "Seerr has no media row for tmdb=${TMDB_ID}"; return 1; }
echo " Seerr media id=${seerr_media_id}"
code=$(curl -s -o /tmp/assemblrr_seerr_del.body -w "%{http_code}" -X DELETE \
"http://$(api_host):5055/api/v1/media/${seerr_media_id}/file?is4k=false" \
-H "X-Api-Key: ${SEERR_KEY}")
echo " HTTP ${code}"
if [ "$code" != "204" ] && [ "$code" != "200" ]; then
fail "Seerr delete file HTTP ${code}: $(head -c 200 /tmp/assemblrr_seerr_del.body 2>/dev/null || true)"
return 1
fi
# CustomScript is async
sleep 8
assert_fully_gone "Seerr delete file cascade"
}
# --- Jellyfin: DELETE /Items/:id ---
test_jellyfin_delete_item() {
echo ""
echo "=== Jellyfin DELETE /Items/:id ==="
local movie_id jf_item code i path
movie_id=$(setup_title)
echo " Radarr movie id=${movie_id}"
curl -s -o /dev/null -X POST "http://$(api_host):8096/Library/Refresh" \
-H "X-Emby-Token: ${JF_KEY}" || true
jf_item=""
for i in $(seq 1 40); do
jf_item=$(curl -s "http://$(api_host):8096/Items?IncludeItemTypes=Movie&Recursive=true&Fields=Path" \
-H "X-Emby-Token: ${JF_KEY}" \
| jq -r --arg p "/data/media/movies/${TITLE_YEAR}" '
.Items[]? | select((.Path // "") | startswith($p)) | .Id
' 2>/dev/null | head -1)
[ -n "$jf_item" ] && break
sleep 2
done
[ -n "$jf_item" ] || { fail "Jellyfin did not index ${TITLE_YEAR}"; return 1; }
path=$(curl -s "http://$(api_host):8096/Items/${jf_item}" -H "X-Emby-Token: ${JF_KEY}" \
| jq -r '.Path // empty' 2>/dev/null || true)
echo " Jellyfin item id=${jf_item} path=${path}"
code=$(curl -s -o /tmp/assemblrr_jf_del.body -w "%{http_code}" -X DELETE \
"http://$(api_host):8096/Items/${jf_item}" -H "X-Emby-Token: ${JF_KEY}")
echo " HTTP ${code}"
if [ "$code" != "204" ] && [ "$code" != "200" ]; then
fail "Jellyfin delete HTTP ${code}: $(head -c 200 /tmp/assemblrr_jf_del.body 2>/dev/null || true)"
return 1
fi
# media-purge-watch debounce + purge
sleep 15
assert_fully_gone "Jellyfin delete item cascade"
}
# --- Radarr direct delete (CustomScript hook) ---
test_radarr_delete() {
echo ""
echo "=== Radarr DELETE /api/v3/movie/:id (purge hook) ==="
local movie_id code
movie_id=$(setup_title)
echo " Radarr movie id=${movie_id}"
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
"http://$(api_host):7878/api/v3/movie/${movie_id}?deleteFiles=true&addImportExclusion=false&apikey=${RADARR_KEY}")
echo " HTTP ${code}"
[ "$code" = "200" ] || { fail "Radarr delete HTTP ${code}"; return 1; }
sleep 8
assert_fully_gone "Radarr delete cascade"
}
# --- main ---
require_live_opt_in
INSTALL_DIR=$(resolve_install_dir) || die "Could not resolve assemblrr install dir"
load_assemblrr_config "$INSTALL_DIR" || true
# shellcheck disable=SC1091
[ -f "$INSTALL_DIR/.env" ] && set -a && source "$INSTALL_DIR/.env" && set +a
MEDIA_DIR="${MEDIA_DIRECTORY:-}"
[ -n "$MEDIA_DIR" ] || MEDIA_DIR="${HOME}/assemblrr-media"
[ -d "$MEDIA_DIR" ] || die "MEDIA_DIRECTORY not found: $MEDIA_DIR"
RADARR_KEY=$(radarr_xml_key)
[ -n "$RADARR_KEY" ] || die "Radarr API key missing"
SEERR_KEY=$(docker exec seerr cat /app/config/settings.json 2>/dev/null | jq -r '.main.apiKey // empty' || true)
[ -n "$SEERR_KEY" ] || SEERR_KEY=$(read_key "$INSTALL_DIR/secrets/seerr_api_key.txt" 2>/dev/null || true)
[ -n "$SEERR_KEY" ] || die "Seerr API key missing (docker seerr settings or secrets)"
JF_KEY=$(read_key "$INSTALL_DIR/secrets/jellyfin_api_key.txt") || die "Jellyfin API key missing"
AUTH_USER=$(read_key "$INSTALL_DIR/secrets/auth_username.txt") || die "auth_username.txt missing"
AUTH_PASS=$(read_key "$INSTALL_DIR/secrets/auth_password.txt") || die "auth_password.txt missing"
# Prerequisites
curl -sf "http://$(api_host):7878/ping" >/dev/null || die "Radarr not reachable"
curl -sf "http://$(api_host):5055/api/v1/settings/public" >/dev/null || die "Seerr not reachable"
curl -sf "http://$(api_host):8096/System/Info/Public" >/dev/null || die "Jellyfin not reachable"
docker inspect media-purge-watch --format '{{.State.Running}}' 2>/dev/null | grep -q true \
|| die "media-purge-watch container is not running"
curl -sf "http://$(api_host):7878/api/v3/notification?apikey=${RADARR_KEY}" \
| jq -e '[.[] | select(.name == "assemblrr Media Purge" and .onMovieDelete == true)] | length > 0' >/dev/null \
|| die "Radarr Media Purge hook not configured (run upgrade / wiring)"
qb_login
trap 'cleanup_bbb; rm -f "$COOKIE_JAR"' EXIT INT TERM
echo "Install: $INSTALL_DIR"
echo "Media: $MEDIA_DIR"
echo "API host: $(api_host)"
echo ""
test_radarr_delete || true
test_seerr_delete_file || true
test_jellyfin_delete_item || true
cleanup_bbb
echo ""
if [ "$FAILED" -gt 0 ]; then
echo "media_purge_e2e: FAILED ($FAILED assertion(s))"
exit 1
fi
echo "media_purge_e2e: OK"
exit 0

View file

@ -68,13 +68,21 @@ run_compose() {
run_integration() {
echo "Running live integration tests..."
if bash "$SCRIPT_DIR/integration/watchdog_stall.sh"; then
echo "-> OK"
else
local rc=$?
echo "-> FAILED (exit $rc)"
FAILED=$((FAILED + 1))
fi
local f
for f in \
"$SCRIPT_DIR/integration/watchdog_stall.sh" \
"$SCRIPT_DIR/integration/media_purge_e2e.sh"
do
echo ""
echo "######## $(basename "$f") ########"
if bash "$f"; then
echo "-> OK"
else
local rc=$?
echo "-> FAILED (exit $rc)"
FAILED=$((FAILED + 1))
fi
done
}
run_lint() {