mirror of
https://github.com/soulis-1256/assemblrr.git
synced 2026-10-20 11:31:52 +00:00
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:
parent
c00377844e
commit
c180732db1
17 changed files with 1188 additions and 102 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
70
bin/cli.sh
70
bin/cli.sh
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/).
|
||||
|
|
|
|||
180
lib/arr.sh
180
lib/arr.sh
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
30
scripts/arr-purge-hook.sh
Executable 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
91
scripts/media-purge-watch.sh
Executable 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
395
scripts/media-purge.sh
Executable 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"
|
||||
|
|
@ -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`).
|
||||
|
|
|
|||
386
tests/integration/media_purge_e2e.sh
Executable file
386
tests/integration/media_purge_e2e.sh
Executable 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
|
||||
22
tests/run.sh
22
tests/run.sh
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue