Scope TV request-delete to requested seasons

Seerr's DELETE /api/v1/media/:id/file is title-scoped and would wipe
every remaining season. For a TV request that is only a subset, the
gateway now deletes those Sonarr episode files and unmonitors them,
then drops the request. Movies and last remaining seasons still use
the title-level cascade.

media-purge --seasons and the inotify watcher match that: a season
path never title-purges (other seasons may only be monitored). qB
cleanup requires a season token, refuses multi-season and S01-03
packs, and uses the same title-boundary rules as title-level. *arr
recycle bin is /data/media/.recycle with 7-day cleanup.

Sonarr uncertainty (missing key, failed episodefile list, empty TV
season list) returns 502 and leaves the request. The gateway mounts
config/sonarr/ as a directory so first install cannot replace
config.xml with a Docker-created directory.
This commit is contained in:
soulis-1256 2026-08-16 12:04:27 +03:00
parent e11ac87d7a
commit 9ac07caf12
No known key found for this signature in database
11 changed files with 1105 additions and 70 deletions

View file

@ -58,6 +58,45 @@ purge_watch_library_folder() {
esac
}
# Season number from a library path (.../Season 2/ep.mkv or .../Specials/...). Empty if none.
purge_watch_season_number() {
local p="${1:-}"
local n
n=$(printf '%s' "$p" | sed -nE 's|.*/[Ss]eason[[:space:]]*0*([0-9]+)/.*|\1|p')
if [ -z "$n" ]; then
n=$(printf '%s' "$p" | sed -nE 's|.*/[Ss]eason[[:space:]]*0*([0-9]+)$|\1|p')
fi
if [ -z "$n" ]; then
case "$p" in
*/[Ss]pecials|*/[Ss]pecials/*) echo 0; return 0 ;;
esac
return 0
fi
echo $((10#$n))
}
purge_watch_season_folder() {
local series_folder="$1"
local season="$2"
local cand
[ -n "$series_folder" ] || return 1
if [ "$season" = "0" ]; then
for cand in "${series_folder}/Specials" "${series_folder}/specials"; do
[ -d "$cand" ] && echo "$cand" && return 0
done
return 1
fi
for cand in \
"${series_folder}/Season ${season}" \
"${series_folder}/Season $(printf '%02d' "$season")" \
"${series_folder}/season ${season}" \
"${series_folder}/season $(printf '%02d' "$season")"
do
[ -d "$cand" ] && echo "$cand" && return 0
done
return 1
}
purge_watch_folder_has_video() {
local folder="$1"
[ -d "$folder" ] || return 1
@ -103,11 +142,44 @@ handle_event() {
local now
now=$(date +%s)
if [ "$folder" = "$LAST_PURGE_PATH" ] && [ $((now - LAST_PURGE_TS)) -lt 15 ]; then
local season
season=$(purge_watch_season_number "$path")
# Dedup per season so S01 then S02 in the same window both run.
local dedup_key="$folder"
[ -n "$season" ] && dedup_key="${folder}/s${season}"
if [ "$dedup_key" = "$LAST_PURGE_PATH" ] && [ $((now - LAST_PURGE_TS)) -lt 15 ]; then
return 0
fi
sleep "$DEBOUNCE_SEC"
local arr_path
case "$folder" in
/data/*) arr_path="$folder" ;;
*) arr_path=$(echo "$folder" | sed -E 's|.*/media/|/data/media/|') ;;
esac
if [ -n "$season" ]; then
# Other seasons may only be monitored or still downloading — never
# treat an empty series folder as "the show is done".
local sfolder
sfolder=$(purge_watch_season_folder "$folder" "$season" || true)
if [ -n "$sfolder" ] && purge_watch_folder_has_video "$sfolder"; then
return 0
fi
sleep "$UPGRADE_GRACE_SEC"
sfolder=$(purge_watch_season_folder "$folder" "$season" || true)
if [ -n "$sfolder" ] && purge_watch_folder_has_video "$sfolder"; then
return 0
fi
LAST_PURGE_PATH="$dedup_key"
LAST_PURGE_TS=$(date +%s)
log "purge $arr_path season ${season}"
"$PURGE" --path "$arr_path" --seasons "$season" || log "purge failed for $arr_path season ${season}"
return 0
fi
if purge_watch_folder_has_video "$folder"; then
return 0
fi
@ -116,13 +188,8 @@ handle_event() {
return 0
fi
LAST_PURGE_PATH="$folder"
LAST_PURGE_PATH="$dedup_key"
LAST_PURGE_TS=$(date +%s)
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"
}

View file

@ -1,9 +1,15 @@
#!/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 --series-id N --seasons 1,2
# media-purge.sh --radarr-event | --sonarr-event | --qb-only --path P
# media-purge.sh --match-self-test
#
# TV: --seasons (or a path under Season N) deletes only those seasons' files,
# unmonitors them, and only removes the series when nothing monitored/on-disk
# remains. qB matching then requires a season token and refuses packs that
# also name another season.
#
# 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))
@ -27,6 +33,8 @@ SERIES_ID=""
PATH_ARG=""
TMDB_ID=""
TVDB_ID=""
# Comma/space-separated season numbers. Empty = whole series (title-level).
SEASONS=""
# Set when we actually removed something worth notifying the media server about.
PURGE_TOUCHED=0
@ -64,6 +72,54 @@ folder_key_from() {
echo ""
}
# "1,02 3" → unique integers as lines. Empty / junk → nothing.
parse_season_list() {
local raw="${1:-}"
[ -n "$raw" ] || return 0
echo "$raw" | tr ',; ' '\n' | sed '/^$/d' | awk '
/^[0-9]+$/ { v=int($0); print v }
' | sort -n | uniq
}
# Path under .../Season N/... or .../Specials/... → season number. Else empty.
path_season_number() {
local p="${1:-}"
local n
n=$(printf '%s' "$p" | sed -nE 's|.*/[Ss]eason[[:space:]]*0*([0-9]+)(/.*)?$|\1|p')
if [ -z "$n" ]; then
n=$(printf '%s' "$p" | sed -nE 's|.*/[Ss]eason[[:space:]]*0*([0-9]+)/.*|\1|p')
fi
if [ -z "$n" ]; then
case "$p" in
*/[Ss]pecials|*/[Ss]pecials/*) echo 0; return 0 ;;
esac
return 0
fi
echo $((10#$n))
}
# Series library folder from a path that may include Season N / file.
path_series_folder() {
local p="${1:-}"
case "$p" in
*/media/tv/*)
echo "$p" | sed -E 's|(.*/media/tv/[^/]+).*|\1|'
;;
*)
echo "$p"
;;
esac
}
seasons_json_from_list() {
local lines="$1"
if [ -z "${lines// }" ]; then
echo "[]"
return 0
fi
echo "$lines" | jq -R . | jq -s -c 'map(tonumber)'
}
discover_api_key() {
local svc="$1"
local env_key="${2:-}"
@ -201,6 +257,75 @@ qb_match_jq() {
'
}
# Season-scoped matcher: require at least one requested season token in the
# name/path, refuse if the torrent also names a season we are not deleting
# (including S01-03 / Season 1-3 ranges), and require the same folder-key
# rules as title-level (short keys skipped). Torrents with no parseable
# season are skipped (under-delete).
qb_match_season_jq() {
jq -r --arg folder "$1" --argjson seasons "$2" '
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}| s[0-9]| season |[-.]s[0-9])"))
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;
def expand_range($a; $b):
if $a <= $b then [range($a; $b + 1)] else [range($b; $a + 1)] end;
def seasons_in($text):
($text | ascii_downcase) as $t
| def nums($re):
[ $t | scan($re) | if type == "array" then .[0] else . end | tonumber ];
def ranges($re):
[ $t | scan($re) | if type == "array" then expand_range(.[0]|tonumber; .[1]|tonumber)[] else empty end ];
(
ranges("s0*([0-9]{1,2})[[:space:]]*[-–][[:space:]]*s?0*([0-9]{1,2})")
+ ranges("season[s]?[[:space:]]*0*([0-9]{1,2})[[:space:]]*[-–][[:space:]]*0*([0-9]{1,2})")
+ nums("s([0-9]{1,2})")
+ nums("season[[:space:]]*0*([0-9]{1,2})")
) | unique;
def in_wanted($n): ($seasons | index($n)) != null;
.[]
| . as $row
| (($row.name // "") + " " + ($row.content_path // "")) as $blob
| (seasons_in($blob)) as $found
| select(
($found | length) > 0
and (any($found[]; in_wanted(.)))
and (all($found[]; in_wanted(.)))
and (
folder_hit($row.name // ""; $folder)
or folder_hit($row.content_path // ""; $folder)
)
)
| .hash
'
}
# 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() {
@ -280,6 +405,29 @@ qbit_delete_by_folder_key() {
qbit_delete_hashes $hashes
}
qbit_delete_by_folder_key_seasons() {
local folder_key="$1"
local seasons_json="$2"
[ -n "$folder_key" ] || { log "qB: no folder key"; return 0; }
[ -n "$seasons_json" ] && [ "$seasons_json" != "[]" ] || { log "qB: no seasons"; return 0; }
local info hashes hash_count
info=$(qbit_torrents_info)
[ -n "$info" ] && [ "$info" != "[]" ] || { log "qB: no torrents"; return 0; }
log "qB: folder key: $folder_key seasons: $seasons_json"
hashes=$(echo "$info" | qb_match_season_jq "$folder_key" "$seasons_json" 2>/dev/null || true)
if [ -z "${hashes// }" ]; then
log "qB: no torrents matched folder key + seasons"
return 0
fi
hash_count=$(echo "$hashes" | grep -c . || true)
log "qB: ${hash_count} torrent(s) matched folder key + seasons"
# shellcheck disable=SC2086
qbit_delete_hashes $hashes
}
# Back-compat name used by older call sites / mental model.
qbit_delete_by_tokens() {
local folder=""
@ -332,6 +480,23 @@ arr_history_hashes() {
fi
}
# Same as series history, but only grab/import rows whose episode season is in $5 (lines of ints).
arr_history_hashes_seasons() {
local base="$1" key="$2" id="$3" seasons_json="$4"
local hist
hist=$(arr_get "$base" "$key" "/api/v3/history?pageSize=250&seriesId=${id}")
echo "$hist" | jq -r --argjson seasons "$seasons_json" '
.records[]? as $r
| (
$r.episode.seasonNumber
// $r.data.seasonNumber
// empty
) as $sn
| select(($sn != null) and (($seasons | index($sn)) != null))
| $r.downloadId // $r.data.torrentInfoHash // empty
' 2>/dev/null | tr 'A-F' 'a-f' | sort -u
}
arr_delete_movie() {
local key="$1" id="$2"
log "Radarr: DELETE movie id=${id} (deleteFiles=true)"
@ -360,6 +525,75 @@ arr_delete_series() {
fi
}
# True when this season list covers every remaining on-disk / monitored season
# (specials/0 ignored unless listed). Used to decide series delete vs season.
seasons_cover_remaining() {
local key="$1" id="$2" seasons_json="$3"
local series files
series=$(arr_get "$SONARR_URL" "$key" "/api/v3/series/${id}")
files=$(arr_get "$SONARR_URL" "$key" "/api/v3/episodefile?seriesId=${id}")
echo "$series" | jq -e --argjson seasons "$seasons_json" --argjson files "${files:-[]}" '
([ $files[]? | .seasonNumber ] | unique) as $on_disk
| ([ .seasons[]? | select(.monitored == true) | .seasonNumber ]) as $mon
| (($on_disk + $mon) | unique | map(select(. != 0 or ($seasons | index(0)) != null))) as $remain
| ($remain | length) > 0
and (all($remain[]; ($seasons | index(.)) != null))
' >/dev/null 2>&1
}
arr_unmonitor_seasons() {
local key="$1" id="$2" seasons_json="$3"
local series updated code
series=$(arr_get "$SONARR_URL" "$key" "/api/v3/series/${id}")
[ -n "$series" ] && [ "$series" != "null" ] || return 0
updated=$(echo "$series" | jq --argjson seasons "$seasons_json" '
.seasons = [ .seasons[] | if ($seasons | index(.seasonNumber)) != null
then .monitored = false else . end ]
')
[ -n "$updated" ] || return 0
if [ "$DRY_RUN" = "1" ]; then log "DRY_RUN: skip unmonitor"; return 0; fi
code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 15 -X PUT \
-H "X-Api-Key: ${key}" -H "Content-Type: application/json" \
-d "$updated" \
"${SONARR_URL}/api/v3/series/${id}" 2>/dev/null || echo "000")
log "Sonarr: unmonitor seasons ${seasons_json} HTTP ${code}"
# Also flip per-episode monitored so missing-search will not re-grab.
local eps ids
eps=$(arr_get "$SONARR_URL" "$key" "/api/v3/episode?seriesId=${id}")
ids=$(echo "$eps" | jq -c --argjson seasons "$seasons_json" \
'[ .[] | select(($seasons | index(.seasonNumber)) != null) | .id ]')
if [ -n "$ids" ] && [ "$ids" != "[]" ]; then
curl -s -o /dev/null --connect-timeout 15 -X PUT \
-H "X-Api-Key: ${key}" -H "Content-Type: application/json" \
-d "{\"episodeIds\":${ids},\"monitored\":false}" \
"${SONARR_URL}/api/v3/episode/monitor" >/dev/null || true
fi
}
arr_delete_series_seasons() {
local key="$1" id="$2" seasons_json="$3"
local files fid code
log "Sonarr: delete episode files for series ${id} seasons ${seasons_json}"
files=$(arr_get "$SONARR_URL" "$key" "/api/v3/episodefile?seriesId=${id}")
while IFS= read -r fid; do
[ -n "$fid" ] || continue
if [ "$DRY_RUN" = "1" ]; then
log "DRY_RUN: skip episodefile ${fid}"
continue
fi
code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 15 -X DELETE \
-H "X-Api-Key: ${key}" \
"${SONARR_URL}/api/v3/episodefile/${fid}" 2>/dev/null || echo "000")
log "Sonarr: DELETE episodefile ${fid} HTTP ${code}"
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
PURGE_TOUCHED=1
fi
done < <(echo "$files" | jq -r --argjson seasons "$seasons_json" \
'.[] | select(($seasons | index(.seasonNumber)) != null) | .id')
arr_unmonitor_seasons "$key" "$id" "$seasons_json"
}
# Exact *arr path match only (no bare endswith of short basenames).
arr_id_for_path() {
local base="$1" key="$2" kind="$3" path="$4"
@ -388,14 +622,21 @@ arr_id_for_path() {
purge_qb() {
local kind="$1" base_url="$2" key="$3" id="$4" title="$5" path="$6"
local seasons_json="${7:-}"
local folder hlist=() filtered=() info h
folder=$(folder_key_from "$path" "$title")
log "qB purge kind=${kind} id=${id} folder_key=${folder:-<none>}"
log "qB purge kind=${kind} id=${id} folder_key=${folder:-<none>} seasons=${seasons_json:-<all>}"
while IFS= read -r h; do
[ -n "$h" ] && hlist+=("$h")
done < <(arr_history_hashes "$base_url" "$key" "$kind" "$id")
if [ -n "$seasons_json" ] && [ "$seasons_json" != "[]" ]; then
while IFS= read -r h; do
[ -n "$h" ] && hlist+=("$h")
done < <(arr_history_hashes_seasons "$base_url" "$key" "$id" "$seasons_json")
else
while IFS= read -r h; do
[ -n "$h" ] && hlist+=("$h")
done < <(arr_history_hashes "$base_url" "$key" "$kind" "$id")
fi
if [ "${#hlist[@]}" -gt 0 ]; then
log "${kind} history candidates: ${hlist[*]}"
@ -405,9 +646,28 @@ purge_qb() {
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
# Season-scoped: drop hashes whose torrent names another season.
if [ -n "$seasons_json" ] && [ "$seasons_json" != "[]" ]; then
local kept=()
local season_hits
season_hits=$(echo "$info" | qb_match_season_jq "$folder" "$seasons_json" 2>/dev/null || true)
for h in "${filtered[@]}"; do
if echo "$season_hits" | grep -qx "$h"; then
kept+=("$h")
else
log "qB: drop history hash $h (other season or no season token)"
fi
done
if [ "${#kept[@]}" -gt 0 ]; then
log "qB: history hashes validated (${#kept[@]}): ${kept[*]}"
qbit_delete_hashes "${kept[@]}"
return 0
fi
else
log "qB: history hashes validated (${#filtered[@]}): ${filtered[*]}"
qbit_delete_hashes "${filtered[@]}"
return 0
fi
fi
log "qB: history hashes not in qB or failed folder check; trying folder key"
else
@ -415,7 +675,11 @@ purge_qb() {
fi
if [ -n "$folder" ]; then
qbit_delete_by_folder_key "$folder"
if [ -n "$seasons_json" ] && [ "$seasons_json" != "[]" ]; then
qbit_delete_by_folder_key_seasons "$folder" "$seasons_json"
else
qbit_delete_by_folder_key "$folder"
fi
else
log "qB: skip token fallback (no safe folder key)"
fi
@ -426,7 +690,7 @@ purge_qb_for_movie() {
}
purge_qb_for_series() {
purge_qb series "$SONARR_URL" "$1" "$2" "$3" "$4"
purge_qb series "$SONARR_URL" "$1" "$2" "$3" "$4" "${5:-}"
}
# Best-effort library refresh so deletes do not leave ghost items.
@ -519,6 +783,47 @@ if [ "${1:-}" = "--match-self-test" ]; then
"Alpha Film (2016)" \
'[{"hash":"hash_b","name":"Alpha Film Sequel Extended (2022)","content_path":"/data/torrents/movies/Alpha Film Sequel Extended (2022)"}]' \
hash_b
check_season() {
local want="$1" name="$2" folder="$3" seasons="$4" torrents="$5"
local got
got=$(echo "$torrents" | qb_match_season_jq "$folder" "$seasons" | 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_season "hash_s1" "S01 pack matches season 1 only" \
"Loki" '[1]' \
'[{"hash":"hash_s1","name":"Loki.S01.COMPLETE.1080p","content_path":"/data/torrents/tv/Loki.S01.COMPLETE.1080p"},{"hash":"hash_s2","name":"Loki.S02.COMPLETE.1080p","content_path":"/data/torrents/tv/Loki.S02.COMPLETE.1080p"}]'
check_season "" "S02 pack is not deleted with season 1" \
"Loki" '[1]' \
'[{"hash":"hash_s2","name":"Loki.S02.COMPLETE.1080p.DSNP.WEB-DL","content_path":"/data/torrents/tv/Loki.S02.COMPLETE.1080p.DSNP.WEB-DL"}]'
check_season "" "multi-season pack refused when deleting S01 only" \
"Loki" '[1]' \
'[{"hash":"hash_both","name":"Loki.S01.S02.COMPLETE","content_path":"/data/torrents/tv/Loki.S01.S02.COMPLETE"}]'
check_season "" "no season token is skipped (under-delete)" \
"Loki" '[1]' \
'[{"hash":"hash_bare","name":"Loki","content_path":"/data/torrents/tv/Loki"}]'
check_season "" "other show S01 is not matched" \
"Loki" '[1]' \
'[{"hash":"hash_x","name":"Other Show.S01.COMPLETE","content_path":"/data/torrents/tv/Other Show.S01.COMPLETE"}]'
check_season "" "S01-03 range pack refused when deleting S01 only" \
"Loki" '[1]' \
'[{"hash":"hash_r","name":"Loki.S01-03.COMPLETE","content_path":"/data/torrents/tv/Loki.S01-03.COMPLETE"}]'
check_season "" "Season 1-3 range pack refused when deleting S01 only" \
"Loki" '[1]' \
'[{"hash":"hash_r2","name":"Loki Season 1-3","content_path":"/data/torrents/tv/Loki Season 1-3"}]'
check_season "" "short folder key does not match any S01 torrent" \
"Oz" '[1]' \
'[{"hash":"hash_oz","name":"Other.S01.COMPLETE","content_path":"/data/torrents/tv/Other.S01.COMPLETE"}]'
check_season "" "Lost S01 does not match Lost in Space S01" \
"Lost" '[1]' \
'[{"hash":"hash_lis","name":"Lost in Space.S01.COMPLETE","content_path":"/data/torrents/tv/Lost in Space.S01.COMPLETE"}]'
check_season "hash_e1 hash_e2" "multiple S01E0x torrents all match" \
"Loki" '[1]' \
'[{"hash":"hash_e1","name":"Loki.S01E01","content_path":"/data/torrents/tv/Loki.S01E01"},{"hash":"hash_e2","name":"Loki.S01E02","content_path":"/data/torrents/tv/Loki.S01E02"}]'
if [ "$failures" -gt 0 ]; then
echo "match-self-test: FAILED ($failures)"
exit 1
@ -535,6 +840,7 @@ while [ $# -gt 0 ]; do
--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 ;;
--seasons) SEASONS="$2"; shift 2 ;;
--radarr-event) MODE=radarr-event; shift ;;
--sonarr-event) MODE=sonarr-event; shift ;;
--qb-only) QB_ONLY=1; shift ;;
@ -617,9 +923,24 @@ case "$MODE" in
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"
season_lines=$(parse_season_list "$SEASONS")
if [ -n "$season_lines" ]; then
seasons_json=$(seasons_json_from_list "$season_lines")
log "series ${SERIES_ID} season-scoped ${seasons_json}"
purge_qb_for_series "$SONARR_API_KEY" "$SERIES_ID" "$title" "$path" "$seasons_json"
if [ "$QB_ONLY" != "1" ]; then
if seasons_cover_remaining "$SONARR_API_KEY" "$SERIES_ID" "$seasons_json"; then
log "requested seasons cover remaining library; deleting series"
arr_delete_series "$SONARR_API_KEY" "$SERIES_ID"
else
arr_delete_series_seasons "$SONARR_API_KEY" "$SERIES_ID" "$seasons_json"
fi
fi
else
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
fi
;;
path)
@ -643,13 +964,29 @@ case "$MODE" in
fi
if [ "$QB_ONLY" != "1" ] && [ -n "$SONARR_API_KEY" ]; then
sid=$(arr_id_for_path "$SONARR_URL" "$SONARR_API_KEY" series "$path")
series_folder=$(path_series_folder "$path")
sid=$(arr_id_for_path "$SONARR_URL" "$SONARR_API_KEY" series "$series_folder")
[ -n "$sid" ] || 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"
inferred=$(path_season_number "$path")
season_lines=$(parse_season_list "${SEASONS:-$inferred}")
if [ -n "$season_lines" ]; then
seasons_json=$(seasons_json_from_list "$season_lines")
log "path series ${sid} season-scoped ${seasons_json}"
purge_qb_for_series "$SONARR_API_KEY" "$sid" "$title" "$spath" "$seasons_json"
if seasons_cover_remaining "$SONARR_API_KEY" "$sid" "$seasons_json"; then
log "requested seasons cover remaining library; deleting series"
arr_delete_series "$SONARR_API_KEY" "$sid"
else
arr_delete_series_seasons "$SONARR_API_KEY" "$sid" "$seasons_json"
fi
else
purge_qb_for_series "$SONARR_API_KEY" "$sid" "$title" "$spath"
arr_delete_series "$SONARR_API_KEY" "$sid"
fi
notify_jellyfin
log "done"
exit 0
@ -657,7 +994,15 @@ case "$MODE" in
fi
log "no *arr row; folder-key qB cleanup only"
qbit_delete_by_folder_key "$(folder_key_from "$path" "$base")"
series_folder=$(path_series_folder "$path")
inferred=$(path_season_number "$path")
season_lines=$(parse_season_list "${SEASONS:-$inferred}")
if [ -n "$season_lines" ]; then
qbit_delete_by_folder_key_seasons "$(folder_key_from "$series_folder" "$base")" \
"$(seasons_json_from_list "$season_lines")"
else
qbit_delete_by_folder_key "$(folder_key_from "$series_folder" "$base")"
fi
;;
tmdb)
[ -n "$TMDB_ID" ] || die "--tmdb required"
@ -673,6 +1018,9 @@ case "$MODE" in
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}"
if [ -n "$SEASONS" ]; then
exec "$0" --series-id "$sid" --seasons "$SEASONS"
fi
exec "$0" --series-id "$sid"
;;
*)

View file

@ -3,9 +3,15 @@
seerr-gateway — reverse proxy for Seerr with delete-request full purge.
Stock Seerr DELETE /api/v1/request/:id only removes the request row. assemblrr
intercepts that call (when enabled), deletes linked media files via Seerr first
(DELETE /api/v1/media/:id/file → Radarr/Sonarr deleteFiles + purge hooks), then
forwards the original request delete.
intercepts that call (when enabled):
* Movies (and TV requests that cover every remaining season): DELETE
/api/v1/media/:id/file → Radarr/Sonarr deleteFiles + purge hooks.
* TV requests for a subset of seasons: delete only those seasons' episode
files via Sonarr and unmonitor them. Other seasons stay. The title-level
Seerr file-delete is skipped so Sonarr does not wipe the series folder.
Then the original request delete is forwarded.
Passthrough: all other methods/paths are proxied unchanged to SEERR_UPSTREAM.
@ -46,6 +52,9 @@ READ_TIMEOUT = float(os.environ.get("SEERR_GATEWAY_READ_TIMEOUT", "120"))
LOG_LEVEL = os.environ.get("SEERR_GATEWAY_LOG_LEVEL", "INFO").upper()
REQUEST_DELETE_RE = re.compile(r"^/api/v1/request/(\d+)/?$")
SONARR_URL = os.environ.get("SONARR_URL", "http://sonarr:8989").rstrip("/")
SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "").strip()
SONARR_CONFIG = os.environ.get("SONARR_CONFIG", "/config/sonarr/config.xml")
HOP_BY_HOP = {
"connection",
@ -189,6 +198,262 @@ def media_ids_from_request(payload: dict) -> Tuple[Optional[int], bool]:
return None, is4k
def request_is_tv(payload: dict) -> bool:
rtype = (payload.get("type") or payload.get("mediaType") or "").lower()
if rtype == "tv":
return True
media = payload.get("media") or payload.get("mediaInfo") or {}
if isinstance(media, dict):
return (media.get("mediaType") or "").lower() == "tv"
return False
def seasons_from_request(payload: dict) -> List[int]:
"""Season numbers listed on this Seerr request (including specials if present)."""
out: List[int] = []
for row in payload.get("seasons") or []:
if not isinstance(row, dict):
continue
try:
n = int(row.get("seasonNumber"))
except (TypeError, ValueError):
continue
out.append(n)
# Unique, stable order
seen = set()
uniq: List[int] = []
for n in out:
if n not in seen:
seen.add(n)
uniq.append(n)
return uniq
def discover_sonarr_api_key() -> str:
if SONARR_API_KEY:
return SONARR_API_KEY
path = SONARR_CONFIG
if not path or not os.path.isfile(path):
return ""
try:
text = open(path, encoding="utf-8").read()
except OSError:
return ""
m = re.search(r"<ApiKey>([^<]+)</ApiKey>", text)
return m.group(1).strip() if m else ""
def _sonarr_request(
method: str,
path: str,
api_key: str,
body: Optional[dict] = None,
timeout: float = 20,
) -> Tuple[int, object]:
url = f"{SONARR_URL}{path}"
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(url, data=data, method=method)
req.add_header("X-Api-Key", api_key)
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
parsed: object = None
if raw:
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
parsed = raw
return resp.status, parsed
except urllib.error.HTTPError as e:
raw = e.read()
parsed = None
if raw:
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
parsed = raw
return e.code, parsed
except (urllib.error.URLError, TimeoutError, socket.timeout, OSError) as e:
raise RuntimeError(f"sonarr {method} {path}: {e}") from e
def sonarr_lookup_series_id(payload: dict, api_key: str) -> Optional[int]:
media = payload.get("media") or payload.get("mediaInfo") or {}
if not isinstance(media, dict):
media = {}
ext = media.get("externalServiceId")
if ext is not None:
try:
return int(ext)
except (TypeError, ValueError):
pass
tvdb = media.get("tvdbId")
if tvdb is None:
return None
try:
tvdb_i = int(tvdb)
except (TypeError, ValueError):
return None
status, series = _sonarr_request("GET", "/api/v3/series", api_key)
if status != 200 or not isinstance(series, list):
return None
for row in series:
if isinstance(row, dict) and row.get("tvdbId") == tvdb_i:
sid = row.get("id")
try:
return int(sid)
except (TypeError, ValueError):
return None
return None
def sonarr_remaining_seasons(series_id: int, api_key: str) -> List[int]:
"""Seasons that still have files or are monitored (skip specials)."""
status, series = _sonarr_request("GET", f"/api/v3/series/{series_id}", api_key)
if status == 404:
return []
if status != 200 or not isinstance(series, dict):
raise RuntimeError(f"sonarr GET series/{series_id} HTTP {status}")
status_f, files = _sonarr_request(
"GET", f"/api/v3/episodefile?seriesId={series_id}", api_key
)
if status_f != 200 or not isinstance(files, list):
raise RuntimeError(f"sonarr GET episodefile HTTP {status_f}")
remain: set = set()
for f in files:
if isinstance(f, dict) and f.get("seasonNumber") not in (None, 0):
remain.add(int(f["seasonNumber"]))
for s in series.get("seasons") or []:
if not isinstance(s, dict):
continue
try:
n = int(s.get("seasonNumber"))
except (TypeError, ValueError):
continue
if n == 0:
continue
if s.get("monitored"):
remain.add(n)
return sorted(remain)
def sonarr_delete_seasons(series_id: int, seasons: List[int], api_key: str) -> int:
"""Delete episode files and unmonitor the given seasons. Returns file-delete count."""
if not seasons:
return 0
wanted = set(seasons)
status, files = _sonarr_request(
"GET", f"/api/v3/episodefile?seriesId={series_id}", api_key
)
if status != 200 or not isinstance(files, list):
raise RuntimeError(f"sonarr GET episodefile HTTP {status}")
deleted = 0
for f in files:
if not isinstance(f, dict):
continue
try:
sn = int(f.get("seasonNumber"))
fid = int(f.get("id"))
except (TypeError, ValueError):
continue
if sn not in wanted:
continue
dstatus, _ = _sonarr_request("DELETE", f"/api/v3/episodefile/{fid}", api_key)
if dstatus in (200, 204):
deleted += 1
elif dstatus == 404:
continue
else:
raise RuntimeError(f"sonarr DELETE episodefile/{fid} HTTP {dstatus}")
status, series = _sonarr_request("GET", f"/api/v3/series/{series_id}", api_key)
if status == 200 and isinstance(series, dict):
changed = False
for s in series.get("seasons") or []:
if isinstance(s, dict) and s.get("seasonNumber") in wanted and s.get("monitored"):
s["monitored"] = False
changed = True
if changed:
pstatus, _ = _sonarr_request("PUT", f"/api/v3/series/{series_id}", api_key, series)
if pstatus not in (200, 202):
raise RuntimeError(f"sonarr PUT series/{series_id} unmonitor HTTP {pstatus}")
elif status not in (200, 404):
raise RuntimeError(f"sonarr GET series/{series_id} HTTP {status}")
status, episodes = _sonarr_request(
"GET", f"/api/v3/episode?seriesId={series_id}", api_key
)
if status == 200 and isinstance(episodes, list):
ids = []
for ep in episodes:
if not isinstance(ep, dict):
continue
try:
if int(ep.get("seasonNumber")) in wanted:
ids.append(int(ep["id"]))
except (TypeError, ValueError, KeyError):
continue
if ids:
pstatus, _ = _sonarr_request(
"PUT",
"/api/v3/episode/monitor",
api_key,
{"episodeIds": ids, "monitored": False},
)
if pstatus not in (200, 202):
raise RuntimeError(f"sonarr PUT episode/monitor HTTP {pstatus}")
elif status not in (200, 404):
raise RuntimeError(f"sonarr GET episode HTTP {status}")
return deleted
def seasons_cover_remaining(requested: List[int], remaining: List[int]) -> bool:
if not remaining:
return True
want = set(requested)
return all(n in want for n in remaining)
def _delete_media_file(
media_id: int,
is4k: bool,
headers: Dict[str, str],
) -> Tuple[str, int]:
is4k_q = "true" if is4k else "false"
file_path = f"/api/v1/media/{media_id}/file?is4k={is4k_q}"
file_status, _, file_body = proxy_once("DELETE", file_path, headers, None)
if 200 <= file_status < 300 or file_status == 404:
note = f"media_file_deleted:{media_id}:{file_status}"
log.info(
"DELETE media/%s/file is4k=%s → HTTP %s",
media_id,
is4k_q,
file_status,
)
return note, file_status
log.warning(
"media file delete failed HTTP %s: %s",
file_status,
file_body[:200],
)
return f"media_file_failed:{media_id}:{file_status}", file_status
def _season_purge_fail(detail: str, note: str) -> Tuple[int, List[Tuple[str, str]], bytes, str]:
return (
502,
[
("Content-Type", "application/json"),
("X-Assemblrr-Request-Delete-Purge", "1"),
("X-Assemblrr-Purge-Detail", note[:200]),
],
json.dumps({"error": "seerr-gateway season purge failed", "detail": detail}).encode(),
note,
)
def purge_then_delete_request(
request_id: str,
path: str,
@ -196,13 +461,18 @@ def purge_then_delete_request(
) -> Tuple[int, List[Tuple[str, str]], bytes, str]:
"""
Cascade:
GET request → DELETE media/:id/file (if linked) → DELETE request
Always attempts request delete so Seerr state stays consistent.
GET request
movie / TV covering every remaining season → DELETE media/:id/file
TV subset of seasons → Sonarr season file delete only
DELETE request
Auth failure on GET, or any TV-scope / Sonarr uncertainty, returns without
deleting the request (no title-level wipe).
Returns (status, headers, body, purge_note).
"""
get_status, _, get_body = proxy_once("GET", f"/api/v1/request/{request_id}", headers, None)
media_id = None
is4k = False
payload: dict = {}
if get_status == 200:
payload = parse_request_payload(get_body)
media_id, is4k = media_ids_from_request(payload)
@ -214,26 +484,49 @@ def purge_then_delete_request(
purge_note = "no_media"
if media_id is not None:
is4k_q = "true" if is4k else "false"
file_path = f"/api/v1/media/{media_id}/file?is4k={is4k_q}"
file_status, _, file_body = proxy_once("DELETE", file_path, headers, None)
if 200 <= file_status < 300 or file_status == 404:
purge_note = f"media_file_deleted:{media_id}:{file_status}"
log.info(
"request %s: DELETE media/%s/file is4k=%s → HTTP %s",
request_id,
media_id,
is4k_q,
file_status,
)
if request_is_tv(payload):
requested = seasons_from_request(payload)
if not requested:
log.warning("request %s: TV request has no season list; refusing title delete", request_id)
return _season_purge_fail("tv request has no seasons", "tv_seasons_unknown")
api_key = discover_sonarr_api_key()
if not api_key:
log.warning("request %s: no Sonarr API key; refusing title delete", request_id)
return _season_purge_fail("sonarr api key missing", "sonarr_key_missing")
try:
series_id = sonarr_lookup_series_id(payload, api_key)
if series_id is None:
# Not in Sonarr — drop the request only (under-delete).
log.info("request %s: series not in Sonarr; request-delete only", request_id)
purge_note = "sonarr_series_missing"
else:
remaining = sonarr_remaining_seasons(series_id, api_key)
if remaining and not seasons_cover_remaining(requested, remaining):
deleted = sonarr_delete_seasons(series_id, requested, api_key)
purge_note = (
f"seasons_deleted:{media_id}:{series_id}:"
f"{','.join(str(s) for s in requested)}:{deleted}"
)
log.info(
"request %s: season-scoped purge seasons=%s remaining_after_intent=%s files=%s",
request_id,
requested,
[n for n in remaining if n not in set(requested)],
deleted,
)
else:
log.info(
"request %s: seasons %s cover remaining %s; title delete",
request_id,
requested,
remaining,
)
purge_note, _ = _delete_media_file(media_id, is4k, headers)
except Exception as exc:
log.error("request %s: season purge failed: %s", request_id, exc)
return _season_purge_fail(str(exc), "season_purge_failed")
else:
purge_note = f"media_file_failed:{media_id}:{file_status}"
log.warning(
"request %s: media file delete failed HTTP %s: %s",
request_id,
file_status,
file_body[:200],
)
purge_note, _ = _delete_media_file(media_id, is4k, headers)
else:
log.info("request %s: no linked media id; deleting request only", request_id)
@ -491,6 +784,93 @@ class _MockSeerr(BaseHTTPRequestHandler):
self._send(404, b'{"error":"not found"}')
class _MockSonarr(BaseHTTPRequestHandler):
"""Sonarr stand-in: series 10 has S01+S02 files unless deleted."""
protocol_version = "HTTP/1.1"
calls: List[str] = []
files: List[dict] = []
series: dict = {}
episodefile_status: int = 200
delete_episodefile_status: int = 200
def log_message(self, fmt: str, *args) -> None:
return
def _send(self, status: int, body: bytes = b"") -> None:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD" and body:
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
_MockSonarr.calls.append(f"GET {self.path}")
if path == "/api/v3/series/10":
self._send(200, json.dumps(_MockSonarr.series).encode())
return
if path == "/api/v3/series":
self._send(200, json.dumps([_MockSonarr.series]).encode())
return
if path == "/api/v3/episodefile":
if _MockSonarr.episodefile_status != 200:
self._send(_MockSonarr.episodefile_status, b'{"error":"episodefile failed"}')
return
self._send(200, json.dumps(_MockSonarr.files).encode())
return
if path == "/api/v3/episode":
eps = [
{"id": 100 + f["id"], "seasonNumber": f["seasonNumber"], "seriesId": 10}
for f in _MockSonarr.files
]
self._send(200, json.dumps(eps).encode())
return
self._send(404, b"{}")
def do_DELETE(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
_MockSonarr.calls.append(f"DELETE {path}")
m = re.match(r"^/api/v3/episodefile/(\d+)$", path)
if m:
if _MockSonarr.delete_episodefile_status != 200:
self._send(_MockSonarr.delete_episodefile_status, b'{"error":"delete failed"}')
return
fid = int(m.group(1))
_MockSonarr.files = [f for f in _MockSonarr.files if f.get("id") != fid]
self._send(200, b"")
return
self._send(404, b"{}")
def do_PUT(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
_MockSonarr.calls.append(f"PUT {path}")
length = int(self.headers.get("Content-Length") or "0")
if length:
self.rfile.read(length)
self._send(202, b"{}")
def _reset_mock_sonarr() -> None:
_MockSonarr.calls = []
_MockSonarr.episodefile_status = 200
_MockSonarr.delete_episodefile_status = 200
_MockSonarr.files = [
{"id": 1, "seasonNumber": 1, "seriesId": 10},
{"id": 2, "seasonNumber": 2, "seriesId": 10},
]
_MockSonarr.series = {
"id": 10,
"title": "Loki",
"tvdbId": 362472,
"seasons": [
{"seasonNumber": 1, "monitored": True},
{"seasonNumber": 2, "monitored": True},
],
}
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
@ -511,6 +891,7 @@ def self_test() -> int:
failures += 1
mock_port = _free_port()
sonarr_port = _free_port()
gw_port = _free_port()
_MockSeerr.calls = []
@ -527,14 +908,21 @@ def self_test() -> int:
mock_httpd.daemon_threads = True
threading.Thread(target=mock_httpd.serve_forever, daemon=True).start()
_reset_mock_sonarr()
sonarr_httpd = ThreadingHTTPServer(("127.0.0.1", sonarr_port), _MockSonarr)
sonarr_httpd.daemon_threads = True
threading.Thread(target=sonarr_httpd.serve_forever, daemon=True).start()
os.environ["SEERR_UPSTREAM"] = f"http://127.0.0.1:{mock_port}"
os.environ["SEERR_DELETE_REQUEST_PURGE"] = "1"
# Re-bind module globals used by handlers
global UPSTREAM, PURGE_ON_DELETE_REQUEST, LISTEN_HOST, LISTEN_PORT
global UPSTREAM, PURGE_ON_DELETE_REQUEST, LISTEN_HOST, LISTEN_PORT, SONARR_URL, SONARR_API_KEY, SONARR_CONFIG
UPSTREAM = os.environ["SEERR_UPSTREAM"].rstrip("/")
PURGE_ON_DELETE_REQUEST = True
LISTEN_HOST = "127.0.0.1"
LISTEN_PORT = gw_port
SONARR_URL = f"http://127.0.0.1:{sonarr_port}"
SONARR_API_KEY = "sonarr-test-key"
gw = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), GatewayHandler)
gw.daemon_threads = True
@ -639,8 +1027,180 @@ def self_test() -> int:
"purge detail notes no_media",
)
# TV S01-only request: do not wipe S02 (no Seerr title-level file delete).
_reset_mock_sonarr()
_MockSeerr.request_payload = {
"id": 8,
"type": "tv",
"is4k": False,
"seasons": [{"seasonNumber": 1, "status": 2}],
"media": {
"id": 6,
"tmdbId": 84958,
"tvdbId": 362472,
"mediaType": "tv",
"externalServiceId": 10,
},
}
_MockSeerr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/8",
{"X-Api-Key": "test-key"},
)
check(st == 204, f"TV S01 request delete → 204 (got {st})")
check(
not any("DELETE /api/v1/media/6/file" in c for c in _MockSeerr.calls),
"TV S01 request does not title-delete media files",
)
check(
any(c.startswith("DELETE /api/v1/request/8") for c in _MockSeerr.calls),
"TV S01 still deletes the request row",
)
check(
any(c == "DELETE /api/v3/episodefile/1" for c in _MockSonarr.calls),
"TV S01 deletes Sonarr S01 episode file",
)
check(
not any(c == "DELETE /api/v3/episodefile/2" for c in _MockSonarr.calls),
"TV S01 does not delete Sonarr S02 episode file",
)
check(
"seasons_deleted" in (hdrs.get("X-Assemblrr-Purge-Detail") or ""),
f"purge detail is season-scoped ({hdrs.get('X-Assemblrr-Purge-Detail')})",
)
check(
all(f.get("seasonNumber") != 1 for f in _MockSonarr.files),
"S01 file removed from mock Sonarr",
)
check(
any(f.get("seasonNumber") == 2 for f in _MockSonarr.files),
"S02 file still in mock Sonarr",
)
# TV request covering every remaining season → title-level delete.
_reset_mock_sonarr()
_MockSeerr.request_payload = {
"id": 9,
"type": "tv",
"is4k": False,
"seasons": [{"seasonNumber": 1}, {"seasonNumber": 2}],
"media": {
"id": 6,
"mediaType": "tv",
"externalServiceId": 10,
"tvdbId": 362472,
},
}
_MockSeerr.calls = []
_MockSonarr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/9",
{"X-Api-Key": "test-key"},
)
check(st == 204, f"TV all-seasons request → 204 (got {st})")
check(
any("DELETE /api/v1/media/6/file" in c for c in _MockSeerr.calls),
"TV all-seasons uses title-level media file delete",
)
check(
not any(c.startswith("DELETE /api/v3/episodefile/") for c in _MockSonarr.calls),
"TV all-seasons does not piecemeal-delete episode files",
)
tv_s01 = {
"id": 8,
"type": "tv",
"is4k": False,
"seasons": [{"seasonNumber": 1, "status": 2}],
"media": {
"id": 6,
"tmdbId": 84958,
"tvdbId": 362472,
"mediaType": "tv",
"externalServiceId": 10,
},
}
# Missing Sonarr key → 502, no title wipe, request kept.
saved_key = SONARR_API_KEY
saved_cfg = SONARR_CONFIG
SONARR_API_KEY = ""
SONARR_CONFIG = "/tmp/assemblrr-no-such-sonarr-config.xml"
_reset_mock_sonarr()
_MockSeerr.request_payload = dict(tv_s01)
_MockSeerr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/8",
{"X-Api-Key": "test-key"},
)
check(st == 502, f"missing Sonarr key → 502 (got {st})")
check(
not any("DELETE /api/v1/media/6/file" in c for c in _MockSeerr.calls),
"missing key does not title-delete",
)
check(
not any(c.startswith("DELETE /api/v1/request/") for c in _MockSeerr.calls),
"missing key keeps the request",
)
check(
"sonarr_key_missing" in (hdrs.get("X-Assemblrr-Purge-Detail") or ""),
"missing key purge detail",
)
SONARR_API_KEY = saved_key
SONARR_CONFIG = saved_cfg
# TV request with empty seasons → 502 (unknown scope).
_reset_mock_sonarr()
_MockSeerr.request_payload = {
"id": 11,
"type": "tv",
"is4k": False,
"seasons": [],
"media": {"id": 6, "mediaType": "tv", "externalServiceId": 10, "tvdbId": 362472},
}
_MockSeerr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/11",
{"X-Api-Key": "test-key"},
)
check(st == 502, f"empty TV seasons → 502 (got {st})")
check(
not any("DELETE /api/v1/media/" in c for c in _MockSeerr.calls),
"empty seasons does not title-delete",
)
check(
not any(c.startswith("DELETE /api/v1/request/") for c in _MockSeerr.calls),
"empty seasons keeps the request",
)
# Failed episodefile GET → 502, no title wipe.
_reset_mock_sonarr()
_MockSonarr.episodefile_status = 500
_MockSeerr.request_payload = dict(tv_s01)
_MockSeerr.calls = []
st, _, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/8",
{"X-Api-Key": "test-key"},
)
check(st == 502, f"episodefile 500 → 502 (got {st})")
check(
not any("DELETE /api/v1/media/6/file" in c for c in _MockSeerr.calls),
"episodefile 500 does not title-delete",
)
check(
not any(c.startswith("DELETE /api/v1/request/") for c in _MockSeerr.calls),
"episodefile 500 keeps the request",
)
_MockSonarr.episodefile_status = 200
gw.shutdown()
mock_httpd.shutdown()
sonarr_httpd.shutdown()
print("")
if failures: