Harden media-purge qB matching and refresh Jellyfin on delete.

Prefer history hashes filtered by library folder key, then exact folder-key
match only (no sequel prefix hits). Refuse ambiguous multi-matches. Ignore
partial file-delete events. Best-effort Jellyfin library refresh after purge.
This commit is contained in:
soulis-1256 2026-08-12 14:26:27 +03:00
parent 7339d1b8b4
commit c2920808eb
No known key found for this signature in database
6 changed files with 419 additions and 91 deletions

View file

@ -26,7 +26,17 @@ Auth failures on step 1 are returned to the client with no deletes.
|-------|------|
| `scripts/seerr-gateway.py` | Reverse proxy + request-delete intercept |
| `compose/base.yaml` → `seerr-gateway` | Publishes 5055; `seerr` is internal only |
| `scripts/arr-purge-hook.sh` / `media-purge.sh` | qB + disk cleanup after *arr delete |
| `scripts/arr-purge-hook.sh` / `media-purge.sh` | qB + disk cleanup after *arr delete; then best-effort Jellyfin library refresh |
### Purge safety (qB)
`media-purge.sh` prefers under-delete over collateral damage:
1. *arr history download hashes that still exist in qB **and** match the library folder key (`Title (YYYY)`)
2. Exact folder-key match on torrent name / content path (no bare short-title prefix)
3. Refuse ambiguous multi-matches
See `media-purge.sh --match-self-test`.
Env:

View file

@ -8,6 +8,8 @@ PURGE="${SCRIPT_DIR}/media-purge.sh"
export API_HOST="${API_HOST:-127.0.0.1}"
export QBITTORRENT_URL="${QBITTORRENT_URL:-}"
export JELLYFIN_URL="${JELLYFIN_URL:-http://jellyfin:8096}"
export JELLYFIN_API_KEY_FILE="${JELLYFIN_API_KEY_FILE:-/run/secrets/jellyfin_api_key.txt}"
export AUTH_USERNAME_FILE="${AUTH_USERNAME_FILE:-/run/secrets/auth_username.txt}"
export AUTH_PASSWORD_FILE="${AUTH_PASSWORD_FILE:-/run/secrets/auth_password.txt}"

View file

@ -11,6 +11,8 @@ 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 JELLYFIN_URL="${JELLYFIN_URL:-http://jellyfin:8096}"
export JELLYFIN_API_KEY_FILE="${JELLYFIN_API_KEY_FILE:-/run/secrets/jellyfin_api_key.txt}"
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}"

View file

@ -2,21 +2,33 @@
# 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
# media-purge.sh --match-self-test
#
# Safety ladder for qB cleanup (prefer under-delete over collateral damage):
# 1) *arr history download hashes that still exist in qB and match the folder key
# 2) Exact library-folder key match on torrent name/content_path (Title (YYYY))
# 3) Never bare short titles when a year-bearing folder exists
# 4) Refuse ambiguous multi-matches on the folder-key step
# After a successful non-qb-only purge, best-effort Jellyfin /Library/Refresh.
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}"
JELLYFIN_URL="${JELLYFIN_URL:-http://jellyfin:8096}"
CONFIG_DIR="${CONFIG_DIR:-}"
DRY_RUN="${DRY_RUN:-0}"
QB_ONLY=0
SKIP_JELLYFIN="${SKIP_JELLYFIN:-0}"
MODE=""
MOVIE_ID=""
SERIES_ID=""
PATH_ARG=""
TMDB_ID=""
TVDB_ID=""
# Set when we actually removed something worth notifying the media server about.
PURGE_TOUCHED=0
log() { echo "media-purge: $*" >&2; }
die() { log "ERROR: $*"; exit 1; }
@ -33,6 +45,25 @@ basename_of() {
echo "${p##*/}"
}
# Library folder key: prefer "Title (YYYY)" basename from *arr path.
folder_key_from() {
local path="${1:-}" title="${2:-}"
local b
if [ -n "$path" ]; then
b=$(basename_of "$path")
if [ "${#b}" -ge 3 ] && [ "$b" != "movies" ] && [ "$b" != "tv" ] && [ "$b" != "media" ]; then
echo "$b"
return 0
fi
fi
# Title alone only if it already carries a year (avoids sequel prefix traps).
if [ -n "$title" ] && echo "$title" | grep -qE '\([0-9]{4}\)'; then
echo "$title"
return 0
fi
echo ""
}
discover_api_key() {
local svc="$1"
local env_key="${2:-}"
@ -91,6 +122,12 @@ qbit_login() {
log "qB session ok (${QBIT_BASE})"
}
qbit_torrents_info() {
qbit_login
curl -s --connect-timeout 10 -b "$QBIT_JAR" -H "Referer: ${QBIT_BASE}/" \
"${QBIT_BASE}/api/v2/torrents/info" 2>/dev/null || echo "[]"
}
qbit_delete_hashes() {
local hashes=("$@")
[ "${#hashes[@]}" -gt 0 ] || return 0
@ -110,65 +147,167 @@ qbit_delete_hashes() {
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
PURGE_TOUCHED=1
}
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
# Folder-key matcher: exact / boundary-safe match only.
# stdin: qB torrents JSON; arg1: JSON string array of folder keys (prefer Title (YYYY)).
qb_match_jq() {
jq -r --argjson tokens "$1" '
def norm:
ascii_downcase
| gsub("\\\\"; "/")
| gsub("[._]+"; " ")
| gsub(" +"; " ")
| gsub("^ +| +$"; "");
def has_year: test("\\([0-9]{4}\\)");
def basename_norm:
gsub("/+$"; "") | split("/") | map(select(length > 0)) | .[-1] // "" | norm;
# After token, only end / " (YYYY" / " [" / " - " / " /" / ".YYYY" — not more title words.
def boundary_ok($t; $tok):
($tok | length) as $m
| ($t | length) as $n
| if $n < $m then false
elif $n == $m then true
else ($t[$m:] | test("^($| \\([0-9]{4}\\)| \\[| - | /|\\.[0-9]{4})"))
end;
def folder_hit($text; $tok):
($text | norm) as $t
| ($tok | norm) as $k
| if ($k | length) < 3 then false
else
($t | basename_norm) as $base
| (
($base == $k)
or ($t == $k)
or ($t | endswith("/" + $k))
or ($t | contains("/" + $k + "/"))
or (($base | startswith($k)) and boundary_ok($base; $k))
or (($t | startswith($k)) and boundary_ok($t; $k))
)
end;
($tokens | map(norm) | map(select(length >= 3)) | unique) as $all
| ([$all[] | select(has_year)]) as $yeared
| (if ($yeared | length) > 0 then $yeared else $all end) as $use
| .[]
| . as $row
| select(
any($use[];
folder_hit($row.name // ""; .)
or folder_hit($row.content_path // ""; .)
)
)
| .hash
'
}
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 "[]")
# Keep history hashes that (a) still exist in qB and (b) match folder key when known.
# stdin: qB info JSON. args: folder_key, then candidate hashes.
qbit_filter_hashes_jq() {
local folder_key="$1"
shift
local hashes_json
# Lowercase/trim only — do not strip non-hex (breaks tests and rare non-standard ids).
hashes_json=$(printf '%s\n' "$@" | jq -R . | jq -s -c 'map(ascii_downcase | gsub("^ +| +$"; "")) | map(select(length > 0)) | unique')
jq -r --argjson want "$hashes_json" --arg folder "$folder_key" '
def norm:
ascii_downcase
| gsub("\\\\"; "/")
| gsub("[._]+"; " ")
| gsub(" +"; " ")
| gsub("^ +| +$"; "");
def basename_norm:
gsub("/+$"; "") | split("/") | map(select(length > 0)) | .[-1] // "" | norm;
def boundary_ok($t; $tok):
($tok | length) as $m
| ($t | length) as $n
| if $n < $m then false
elif $n == $m then true
else ($t[$m:] | test("^($| \\([0-9]{4}\\)| \\[| - | /|\\.[0-9]{4})"))
end;
def folder_hit($text; $tok):
($tok | length) == 0 or (
($text | norm) as $t
| ($tok | norm) as $k
| ($t | basename_norm) as $base
| (
($base == $k)
or ($t | endswith("/" + $k))
or ($t | contains("/" + $k + "/"))
or (($base | startswith($k)) and boundary_ok($base; $k))
)
);
# index==0 is falsy in jq — must compare to null explicitly
[ .[] | select((.hash | ascii_downcase) as $h | (($want | index($h)) != null)) ] as $live
| if ($folder | length) == 0 then
$live[] | .hash
else
($live
| map(select(
folder_hit(.name // ""; $folder)
or folder_hit(.content_path // ""; $folder)
))
) as $matched
| if ($matched | length) > 0 then $matched[] | .hash
else empty
end
end
'
}
qbit_delete_by_folder_key() {
local folder_key="$1"
[ -n "$folder_key" ] || { log "qB: no folder key"; return 0; }
local info hashes hash_count tokens_json
info=$(qbit_torrents_info)
[ -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)
tokens_json=$(jq -nc --arg k "$folder_key" '[$k]')
log "qB: folder key: $folder_key"
hashes=$(echo "$info" | qb_match_jq "$tokens_json" 2>/dev/null || true)
if [ -z "${hashes// }" ]; then
log "qB: no torrents matched tokens"
log "qB: no torrents matched folder key"
return 0
fi
hash_count=$(echo "$hashes" | grep -c . || true)
if [ "${hash_count:-0}" -gt 1 ]; then
log "qB: WARNING ${hash_count} torrents matched folder key (refusing multi-delete). hashes=$(echo "$hashes" | tr '\n' ' ')"
return 0
fi
# shellcheck disable=SC2086
qbit_delete_hashes $hashes
}
# Back-compat name used by older call sites / mental model.
qbit_delete_by_tokens() {
local folder=""
local t b
for t in "$@"; do
[ -n "$t" ] || continue
b=$(basename_of "$t")
if echo "$b" | grep -qE '\([0-9]{4}\)'; then
folder="$b"
break
fi
done
if [ -z "$folder" ]; then
for t in "$@"; do
[ -n "$t" ] || continue
b=$(basename_of "$t")
case "$b" in
media|movies|tv|torrents|data|incomplete|"") continue ;;
esac
if [ "${#b}" -ge 10 ]; then
folder="$b"
break
fi
done
fi
qbit_delete_by_folder_key "$folder"
}
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
@ -185,7 +324,7 @@ arr_history_hashes() {
| .downloadId? // .data.torrentInfoHash? // empty
' 2>/dev/null | tr 'A-F' 'a-f' | sort -u
else
endpoint="/api/v3/history?pageSize=100&seriesId=${id}"
endpoint="/api/v3/history?pageSize=250&seriesId=${id}"
hist=$(arr_get "$base" "$key" "$endpoint")
echo "$hist" | jq -r '
.records[]? | .downloadId // .data.torrentInfoHash // empty
@ -202,6 +341,9 @@ arr_delete_movie() {
-H "X-Api-Key: ${key}" \
"${RADARR_URL}/api/v3/movie/${id}?deleteFiles=true&addImportExclusion=false" 2>/dev/null || echo "000")
log "Radarr: HTTP ${code}"
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
PURGE_TOUCHED=1
fi
}
arr_delete_series() {
@ -213,38 +355,178 @@ arr_delete_series() {
-H "X-Api-Key: ${key}" \
"${SONARR_URL}/api/v3/series/${id}?deleteFiles=true&addImportListExclusion=false" 2>/dev/null || echo "000")
log "Sonarr: HTTP ${code}"
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
PURGE_TOUCHED=1
fi
}
# Exact *arr path match only (no bare endswith of short basenames).
arr_id_for_path() {
local base="$1" key="$2" kind="$3" path="$4"
local b json
b=$(basename_of "$path")
if [ "$kind" = "movie" ]; then
json=$(arr_get "$base" "$key" "/api/v3/movie")
echo "$json" | jq -r --arg p "$path" --arg b "$b" '
.[] | select(
(.path // "") == $p
or ((.path // "") == ($p | sub("/$"; "")))
or ((.path // "") | endswith("/" + $b))
) | .id
' 2>/dev/null | head -1
else
json=$(arr_get "$base" "$key" "/api/v3/series")
echo "$json" | jq -r --arg p "$path" --arg b "$b" '
.[] | select(
(.path // "") == $p
or ((.path // "") == ($p | sub("/$"; "")))
or ((.path // "") | endswith("/" + $b))
) | .id
' 2>/dev/null | head -1
fi
}
purge_qb() {
local kind="$1" base_url="$2" key="$3" id="$4" title="$5" path="$6"
local folder hlist=() filtered=() info h
folder=$(folder_key_from "$path" "$title")
log "qB purge kind=${kind} id=${id} folder_key=${folder:-<none>}"
while IFS= read -r h; do
[ -n "$h" ] && hlist+=("$h")
done < <(arr_history_hashes "$base_url" "$key" "$kind" "$id")
if [ "${#hlist[@]}" -gt 0 ]; then
log "${kind} history candidates: ${hlist[*]}"
info=$(qbit_torrents_info)
while IFS= read -r h; do
[ -n "$h" ] && filtered+=("$h")
done < <(echo "$info" | qbit_filter_hashes_jq "$folder" "${hlist[@]}" 2>/dev/null || true)
if [ "${#filtered[@]}" -gt 0 ]; then
log "qB: history hashes validated (${#filtered[@]}): ${filtered[*]}"
qbit_delete_hashes "${filtered[@]}"
return 0
fi
log "qB: history hashes not in qB or failed folder check; trying folder key"
else
log "no history hashes for ${kind} ${id}"
fi
if [ -n "$folder" ]; then
qbit_delete_by_folder_key "$folder"
else
log "qB: skip token fallback (no safe folder key)"
fi
}
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 movie "$RADARR_URL" "$1" "$2" "$3" "$4"
}
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[@]}"
purge_qb series "$SONARR_URL" "$1" "$2" "$3" "$4"
}
# Best-effort library refresh so deletes do not leave ghost items.
notify_jellyfin() {
if [ "$SKIP_JELLYFIN" = "1" ] || [ "$DRY_RUN" = "1" ]; then
return 0
fi
if [ "$PURGE_TOUCHED" != "1" ]; then
return 0
fi
# Only meaningful for Jellyfin (and Emby-compatible token header).
case "${MEDIA_SERVICE:-jellyfin}" in
jellyfin|emby|"") ;;
*) log "media server ${MEDIA_SERVICE}: skip library refresh"; return 0 ;;
esac
local key="" url code
if [ -n "${JELLYFIN_API_KEY:-}" ]; then
key="$JELLYFIN_API_KEY"
elif [ -f "${JELLYFIN_API_KEY_FILE:-/run/secrets/jellyfin_api_key.txt}" ]; then
key=$(read_secret_file "${JELLYFIN_API_KEY_FILE:-/run/secrets/jellyfin_api_key.txt}" 2>/dev/null || true)
elif [ -n "${CONFIG_DIR:-}" ] && [ -f "${CONFIG_DIR}/../secrets/jellyfin_api_key.txt" ]; then
key=$(read_secret_file "${CONFIG_DIR}/../secrets/jellyfin_api_key.txt" 2>/dev/null || true)
elif [ -n "${INSTALL_DIR:-}" ] && [ -f "${INSTALL_DIR}/secrets/jellyfin_api_key.txt" ]; then
key=$(read_secret_file "${INSTALL_DIR}/secrets/jellyfin_api_key.txt" 2>/dev/null || true)
fi
if [ -z "$key" ]; then
log "Jellyfin: no API key; skip library refresh"
return 0
fi
url="${JELLYFIN_URL%/}/Library/Refresh"
code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 \
-X POST -H "X-Emby-Token: ${key}" "$url" 2>/dev/null || echo "000")
if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
log "Jellyfin: library refresh triggered (HTTP ${code})"
else
log "no history hashes for series ${id}; token fallback"
qbit_delete_by_tokens "$title" "$path" "$(basename_of "$path")"
log "Jellyfin: library refresh failed (HTTP ${code})"
fi
}
# Offline matcher self-test (no Docker / qB): media-purge.sh --match-self-test
if [ "${1:-}" = "--match-self-test" ]; then
failures=0
check() {
local want="$1" name="$2" tokens="$3" torrents="$4"
local got
got=$(echo "$torrents" | qb_match_jq "$tokens" | tr '\n' ' ' | sed 's/[[:space:]]*$//')
if [ "$got" = "$want" ]; then
echo " PASS: $name"
else
echo " FAIL: $name (want='$want' got='$got')"
failures=$((failures + 1))
fi
}
check_filter() {
local want="$1" name="$2" folder="$3" torrents="$4"
shift 4
local got
got=$(echo "$torrents" | qbit_filter_hashes_jq "$folder" "$@" | tr '\n' ' ' | sed 's/[[:space:]]*$//')
if [ "$got" = "$want" ]; then
echo " PASS: $name"
else
echo " FAIL: $name (want='$want' got='$got')"
failures=$((failures + 1))
fi
}
echo "=== media-purge qB match self-test ==="
check "hash_ds" "year folder hits only 2016" \
'["Doctor Strange (2016)"]' \
'[{"hash":"hash_ds","name":"Doctor Strange (2016) [YTS]","content_path":"/data/torrents/movies/Doctor Strange (2016)"},{"hash":"hash_mom","name":"Doctor Strange in the Multiverse of Madness (2022)","content_path":"/data/torrents/movies/Doctor Strange in the Multiverse of Madness (2022)"}]'
check "" "bare Doctor Strange does not hit Multiverse" \
'["Doctor Strange"]' \
'[{"hash":"hash_mom","name":"Doctor Strange in the Multiverse of Madness (2022)","content_path":"/data/torrents/movies/Doctor Strange in the Multiverse of Madness (2022)"}]'
check "hash_ds" "bare Doctor Strange hits exact title only" \
'["Doctor Strange"]' \
'[{"hash":"hash_ds","name":"Doctor Strange","content_path":"/data/torrents/movies/Doctor Strange"},{"hash":"hash_mom","name":"Doctor Strange in the Multiverse of Madness","content_path":"/data/torrents/movies/Doctor Strange in the Multiverse of Madness"}]'
check "hash_bbb" "year preferred when bare+year present" \
'["Big Buck Bunny","Big Buck Bunny (2008)"]' \
'[{"hash":"hash_bbb","name":"Big Buck Bunny (2008) [e2e]","content_path":"/data/torrents/movies/Big Buck Bunny (2008) [e2e]"},{"hash":"hash_other","name":"Big Buck Bunny Adventures","content_path":"/data/torrents/movies/Big Buck Bunny Adventures"}]'
check "hash_m1" "The Matrix bare does not hit Reloaded" \
'["The Matrix"]' \
'[{"hash":"hash_m1","name":"The Matrix","content_path":"/data/torrents/movies/The Matrix"},{"hash":"hash_m2","name":"The Matrix Reloaded","content_path":"/data/torrents/movies/The Matrix Reloaded"}]'
# History filter: drop hash that does not match folder
check_filter "hash_ds" "history filter keeps folder-matched hash only" \
"Doctor Strange (2016)" \
'[{"hash":"hash_ds","name":"Doctor Strange (2016)","content_path":"/data/torrents/movies/Doctor Strange (2016)"},{"hash":"hash_mom","name":"Doctor Strange in the Multiverse of Madness (2022)","content_path":"/data/torrents/movies/Doctor Strange in the Multiverse of Madness (2022)"}]' \
hash_ds hash_mom
check_filter "" "history filter drops all when none match folder" \
"Doctor Strange (2016)" \
'[{"hash":"hash_mom","name":"Doctor Strange in the Multiverse of Madness (2022)","content_path":"/data/torrents/movies/Doctor Strange in the Multiverse of Madness (2022)"}]' \
hash_mom
if [ "$failures" -gt 0 ]; then
echo "match-self-test: FAILED ($failures)"
exit 1
fi
echo "match-self-test: OK"
exit 0
fi
# --- args ---
while [ $# -gt 0 ]; do
case "$1" in
@ -257,6 +539,7 @@ while [ $# -gt 0 ]; do
--sonarr-event) MODE=sonarr-event; shift ;;
--qb-only) QB_ONLY=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--skip-jellyfin) SKIP_JELLYFIN=1; shift ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) die "unknown arg: $1" ;;
esac
@ -271,7 +554,12 @@ case "$MODE" in
log "radarr event type=${et}"
case "$et" in
Test) log "Test ok"; exit 0 ;;
MovieDelete|MovieFileDelete) ;;
# Movie-level only: file deletes are partial and must not wipe whole qB items by title.
MovieDelete) ;;
MovieFileDelete)
log "ignore MovieFileDelete (movie-level purge only)"
exit 0
;;
*) log "ignore ${et}"; exit 0 ;;
esac
mid="${radarr_movie_id:-}"
@ -280,9 +568,11 @@ case "$MODE" in
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:-}")"
qbit_delete_by_folder_key "$(folder_key_from "$path" "$title")"
fi
# Files already removed by Radarr; still refresh Jellyfin.
PURGE_TOUCHED=1
notify_jellyfin
exit 0
;;
sonarr-event)
@ -290,7 +580,11 @@ case "$MODE" in
log "sonarr event type=${et}"
case "$et" in
Test) log "Test ok"; exit 0 ;;
SeriesDelete|EpisodeFileDelete) ;;
SeriesDelete) ;;
EpisodeFileDelete)
log "ignore EpisodeFileDelete (series-level purge only)"
exit 0
;;
*) log "ignore ${et}"; exit 0 ;;
esac
sid="${sonarr_series_id:-}"
@ -299,8 +593,10 @@ case "$MODE" in
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")"
qbit_delete_by_folder_key "$(folder_key_from "$path" "$title")"
fi
PURGE_TOUCHED=1
notify_jellyfin
exit 0
;;
movie)
@ -333,43 +629,35 @@ case "$MODE" in
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)
mid=$(arr_id_for_path "$RADARR_URL" "$RADARR_API_KEY" movie "$path")
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"
notify_jellyfin
log "done"
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)
sid=$(arr_id_for_path "$SONARR_URL" "$SONARR_API_KEY" series "$path")
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"
notify_jellyfin
log "done"
exit 0
fi
fi
log "no *arr row; token-only qB cleanup"
qbit_delete_by_tokens "$base" "$path"
log "no *arr row; folder-key qB cleanup only"
qbit_delete_by_folder_key "$(folder_key_from "$path" "$base")"
;;
tmdb)
[ -n "$TMDB_ID" ] || die "--tmdb required"
@ -392,4 +680,7 @@ case "$MODE" in
;;
esac
if [ "$QB_ONLY" != "1" ]; then
notify_jellyfin
fi
log "done"

View file

@ -39,6 +39,7 @@ run_unit() {
"$SCRIPT_DIR/unit/test_compose.sh" \
"$SCRIPT_DIR/unit/test_core.sh" \
"$SCRIPT_DIR/unit/test_env_example.sh" \
"$SCRIPT_DIR/unit/test_media_purge_match.sh" \
"$SCRIPT_DIR/unit/test_prepare_install_dirs.sh" \
"$SCRIPT_DIR/unit/test_seerr_gateway.sh" \
"$SCRIPT_DIR/unit/test_upgrade.sh" \

View file

@ -0,0 +1,22 @@
#!/bin/bash
# Unit tests for media-purge qB token matching (Doctor Strange vs Multiverse, etc.)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/../helpers.sh"
test_suite "media-purge qB token match"
purge="$REPO_ROOT/scripts/media-purge.sh"
assert_true "media-purge.sh exists" "[ -f \"$purge\" ]"
if bash "$purge" --match-self-test; then
_TEST_PASSES=$((_TEST_PASSES + 1))
echo " PASS: media-purge --match-self-test"
else
_TEST_FAILURES=$((_TEST_FAILURES + 1))
echo " FAIL: media-purge --match-self-test"
fi
test_summary