mirror of
https://github.com/soulis-1256/assemblrr.git
synced 2026-10-20 11:31:52 +00:00
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:
parent
e11ac87d7a
commit
9ac07caf12
11 changed files with 1105 additions and 70 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
;;
|
||||
*)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue