#!/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 queue + history download hashes that still exist in qB (hash is identity) # 2) Title + year match on torrent name / content path (The Avengers (2012) == # The Avengers 2012 REPACK…; indexer prefixes allowed; next token after the # title must be that year). No-year keys still require a season/year/end # boundary so Lost does not hit Lost in Space. # 3) Never bare short titles (len < 3). Refuse ambiguous multi-matches on # the name step. Season purge still refuses packs / other seasons. # After a successful non-qb-only purge, best-effort Jellyfin /Library/Refresh. set -euo pipefail MEDIA_ROOT="${MEDIA_ROOT:-/data}" API_HOST="${API_HOST:-127.0.0.1}" RADARR_URL="${RADARR_URL:-http://${API_HOST}:7878}" SONARR_URL="${SONARR_URL:-http://${API_HOST}:8989}" JELLYFIN_URL="${JELLYFIN_URL:-http://jellyfin:8096}" CONFIG_DIR="${CONFIG_DIR:-}" DRY_RUN="${DRY_RUN:-0}" QB_ONLY=0 SKIP_JELLYFIN="${SKIP_JELLYFIN:-0}" MODE="" MOVIE_ID="" SERIES_ID="" PATH_ARG="" TMDB_ID="" TVDB_ID="" # 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 log() { echo "media-purge: $*" >&2; } die() { log "ERROR: $*"; exit 1; } read_secret_file() { local f="$1" [ -f "$f" ] || return 1 tr -d '\r\n' <"$f" } basename_of() { local p="$1" p="${p%/}" echo "${p##*/}" } # Library folder key: prefer "Title (YYYY)" basename from *arr path. folder_key_from() { local path="${1:-}" title="${2:-}" local b if [ -n "$path" ]; then b=$(basename_of "$path") if [ "${#b}" -ge 3 ] && [ "$b" != "movies" ] && [ "$b" != "tv" ] && [ "$b" != "media" ]; then echo "$b" return 0 fi fi # Title alone only if it already carries a year (avoids sequel prefix traps). if [ -n "$title" ] && echo "$title" | grep -qE '\([0-9]{4}\)'; then echo "$title" return 0 fi echo "" } # "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:-}" if [ -n "${env_key}" ]; then echo "$env_key" return 0 fi local f for f in \ "${CONFIG_DIR:+$CONFIG_DIR/$svc/config.xml}" \ "/config/$svc/config.xml" \ "${INSTALL_DIR:+$INSTALL_DIR/config/$svc/config.xml}"; do [ -n "${f:-}" ] && [ -f "$f" ] || continue local k k=$(sed -n 's/.*\(.*\)<\/ApiKey>.*/\1/p' "$f" 2>/dev/null | head -1) if [ -n "$k" ]; then echo "$k" return 0 fi done return 1 } qbit_login() { local user pass jar jar="${1:-/tmp/media-purge-qb.cookies}" user="${AUTH_USERNAME:-}" pass="${AUTH_PASSWORD:-}" [ -z "$user" ] && user=$(read_secret_file "${AUTH_USERNAME_FILE:-/run/secrets/auth_username.txt}" 2>/dev/null || true) [ -z "$pass" ] && pass=$(read_secret_file "${AUTH_PASSWORD_FILE:-/run/secrets/auth_password.txt}" 2>/dev/null || true) [ -n "$user" ] && [ -n "$pass" ] || die "qBittorrent credentials missing" if [ -n "${QBITTORRENT_URL:-}" ]; then QBIT_BASE="$QBITTORRENT_URL" else QBIT_BASE="" local try for try in "http://gluetun:8081" "http://qbittorrent:8081" "http://${API_HOST}:8081" "http://127.0.0.1:8081"; do if curl -s --connect-timeout 2 -o /dev/null -w "%{http_code}" "${try}/" 2>/dev/null | grep -qE '^[2345]'; then QBIT_BASE="$try" break fi done [ -n "$QBIT_BASE" ] || QBIT_BASE="http://${API_HOST}:8081" fi QBIT_JAR="$jar" curl -s --connect-timeout 8 -c "$QBIT_JAR" \ -H "Referer: ${QBIT_BASE}/" \ --data-urlencode "username=${user}" \ --data-urlencode "password=${pass}" \ "${QBIT_BASE}/api/v2/auth/login" >/dev/null || true if ! curl -s --connect-timeout 5 -b "$QBIT_JAR" -H "Referer: ${QBIT_BASE}/" \ "${QBIT_BASE}/api/v2/app/version" 2>/dev/null | grep -q .; then die "qBittorrent login failed (${QBIT_BASE})" fi log "qB session ok (${QBIT_BASE})" } qbit_torrents_info() { qbit_login curl -s --connect-timeout 10 -b "$QBIT_JAR" -H "Referer: ${QBIT_BASE}/" \ "${QBIT_BASE}/api/v2/torrents/info" 2>/dev/null || echo "[]" } qbit_delete_hashes() { local hashes=("$@") [ "${#hashes[@]}" -gt 0 ] || return 0 qbit_login local csv="" h for h in "${hashes[@]}"; do h=$(echo "$h" | tr 'A-F' 'a-f' | tr -d '[:space:]') [ -n "$h" ] || continue [ -n "$csv" ] && csv="${csv}|${h}" || csv="$h" done [ -n "$csv" ] || return 0 log "qB: deleting hashes with files: $csv" if [ "$DRY_RUN" = "1" ]; then log "DRY_RUN: skip qB delete" return 0 fi curl -s --connect-timeout 15 -b "$QBIT_JAR" -H "Referer: ${QBIT_BASE}/" \ -d "hashes=${csv}&deleteFiles=true" \ "${QBIT_BASE}/api/v2/torrents/delete" >/dev/null || true PURGE_TOUCHED=1 } # Title + year matcher shared by name fallback and leftover-dir cleanup. # A movie key "The Avengers (2012)" hits "The Avengers 2012 REPACK…" and # "www.UIndex.org - The Avengers 2012 …" because the title tokens appear # consecutively and the next token is that year. It does not hit Age of Ultron # (next token is not 2012) or "It Comes at Night" for It (2017). QB_TITLE_YEAR_JQ=$(cat <<'JQ' def norm: ascii_downcase | gsub("\\\\"; "/") | gsub("['’]"; "") | gsub("[^a-z0-9]+"; " ") | gsub(" +"; " ") | gsub("^ +| +$"; ""); def tokens($text): [($text | norm) | split(" ")[] | select(length > 0)]; def basename_of($text): ($text | gsub("\\\\"; "/") | gsub("/+$"; "") | split("/") | map(select(length > 0)) | .[-1] // ""); def is_year: test("^[0-9]{4}$"); def is_season_tok: test("^s[0-9]{1,2}(e[0-9]{1,3})?$") or . == "season" or . == "specials"; def parse_folder($folder): ($folder // "") as $raw | ([$raw | ascii_downcase | scan("\\(([0-9]{4})\\)") | if type == "array" then .[0] else . end] | .[-1] // "") as $paren_year | (tokens($raw | gsub("\\([0-9]{4}\\)"; " "))) as $words | if $paren_year != "" then {title: $words, year: $paren_year} elif (($words | length) > 0) and ($words[-1] | is_year) then {title: $words[0:-1], year: $words[-1]} else {title: $words, year: ""} end; def drop_articles($words): if ($words | length) > 1 and ($words[0] == "the" or $words[0] == "a" or $words[0] == "an") then $words[1:] else $words end; def find_title_then($toks; $title; $need_year): ($title | length) as $n | if $n == 0 then false else any(range(0; ($toks | length) - $n + 1); . as $i | $toks[$i:$i+$n] == $title and ( ($toks[$i+$n] // "") as $next | if $need_year != "" then $next == $need_year else $next == "" or ($next | is_season_tok) or ($next | is_year) end ) ) end; def title_year_hit($text; $folder): if ($folder | length) == 0 or (($folder | norm | length) < 3) then false else parse_folder($folder) as $fk | tokens($text) as $toks | tokens(basename_of($text)) as $base | ($fk.title | length) > 0 and ( ($text | norm) == ($folder | norm) or (basename_of($text) | norm) == ($folder | norm) or any([$fk.title, drop_articles($fk.title)][]; . as $cand | ($cand | length) > 0 and (find_title_then($toks; $cand; $fk.year) or find_title_then($base; $cand; $fk.year)) ) ) end; def has_year: test("\\([0-9]{4}\\)") or test("(^| )[0-9]{4}$"); JQ ) # stdin: qB torrents JSON; arg1: JSON string array of folder keys (prefer Title (YYYY)). qb_match_jq() { jq -r --argjson tokens "$1" "${QB_TITLE_YEAR_JQ}"' ($tokens | map(select(length >= 3)) | unique) as $all | ([$all[] | select(has_year)]) as $yeared | (if ($yeared | length) > 0 then $yeared else $all end) as $use | .[] | . as $row | select( any($use[]; title_year_hit($row.name // ""; .) or title_year_hit($row.content_path // ""; .) ) ) | .hash ' } # Season-scoped matcher: title+year (or no-year title boundary) plus season # tokens. Refuse packs that also name a season we are not deleting # (S01.S02 / S01-03 / Season 1-3). No season token → skip (under-delete). qb_match_season_jq() { jq -r --arg folder "$1" --argjson seasons "$2" "${QB_TITLE_YEAR_JQ}"' 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 ( title_year_hit($row.name // ""; $folder) or title_year_hit($row.content_path // ""; $folder) ) ) | .hash ' } # Keep *arr queue/history hashes that still exist in qB. The hash is identity — # do not also require a folder-key name match (release names rarely look like # "Title (YYYY)"). stdin: qB info JSON. args: candidate hashes. qbit_filter_hashes_jq() { local hashes_json # Lowercase/trim only — do not strip non-hex (breaks tests and rare non-standard ids). hashes_json=$(printf '%s\n' "$@" | jq -R . | jq -s -c 'map(ascii_downcase | gsub("^ +| +$"; "")) | map(select(length > 0)) | unique') jq -r --argjson want "$hashes_json" ' .[] | select((.hash | ascii_downcase) as $h | (($want | index($h)) != null)) | .hash ' } qbit_delete_by_folder_key() { local folder_key="$1" [ -n "$folder_key" ] || { log "qB: no folder key"; return 0; } local info hashes hash_count tokens_json info=$(qbit_torrents_info) if [ -n "$info" ] && [ "$info" != "[]" ]; then tokens_json=$(jq -nc --arg k "$folder_key" '[$k]') log "qB: folder key: $folder_key" hashes=$(echo "$info" | qb_match_jq "$tokens_json" 2>/dev/null || true) if [ -z "${hashes// }" ]; then log "qB: no torrents matched folder key" else hash_count=$(echo "$hashes" | grep -c . || true) if [ "${hash_count:-0}" -gt 1 ]; then log "qB: WARNING ${hash_count} torrents matched folder key (refusing multi-delete). hashes=$(echo "$hashes" | tr '\n' ' ')" else # shellcheck disable=SC2086 qbit_delete_hashes $hashes fi fi else log "qB: no torrents (will still remove leftover download dirs)" fi remove_orphan_torrent_dirs "$folder_key" } # *arr Completed Download Handling often removes the qB item but leaves # torrents/tv|movies/. Title-delete then finds no qB row. remove_orphan_torrent_dirs() { local folder_key="$1" local seasons_json="${2:-}" local root="${MEDIA_ROOT:-/data}/torrents" local d base info tokens_json hashes [ -n "$folder_key" ] || return 0 [ -d "$root/tv" ] || [ -d "$root/movies" ] || return 0 tokens_json=$(jq -nc --arg k "$folder_key" '[$k]') shopt -s nullglob for d in "$root/tv"/* "$root/movies"/* \ "$root/incomplete/tv"/* "$root/incomplete/movies"/*; do [ -d "$d" ] || continue base=$(basename_of "$d") case "$base" in tv|movies|incomplete|""|.) continue ;; esac info=$(jq -nc --arg n "$base" --arg p "$d" '[{hash:"fs",name:$n,content_path:$p}]') if [ -n "$seasons_json" ] && [ "$seasons_json" != "[]" ]; then hashes=$(echo "$info" | qb_match_season_jq "$folder_key" "$seasons_json" 2>/dev/null || true) else hashes=$(echo "$info" | qb_match_jq "$tokens_json" 2>/dev/null || true) fi [ -n "${hashes// }" ] || continue log "removing leftover download dir: $d" if [ "$DRY_RUN" = "1" ]; then log "DRY_RUN: skip rm $d" continue fi rm -rf -- "$d" && PURGE_TOUCHED=1 done shopt -u nullglob } 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) if [ -n "$info" ] && [ "$info" != "[]" ]; then 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" else hash_count=$(echo "$hashes" | grep -c . || true) log "qB: ${hash_count} torrent(s) matched folder key + seasons" # shellcheck disable=SC2086 qbit_delete_hashes $hashes fi else log "qB: no torrents (will still remove leftover download dirs)" fi remove_orphan_torrent_dirs "$folder_key" "$seasons_json" } # Back-compat name used by older call sites / mental model. qbit_delete_by_tokens() { local folder="" local t b for t in "$@"; do [ -n "$t" ] || continue b=$(basename_of "$t") if echo "$b" | grep -qE '\([0-9]{4}\)'; then folder="$b" break fi done if [ -z "$folder" ]; then for t in "$@"; do [ -n "$t" ] || continue b=$(basename_of "$t") case "$b" in media|movies|tv|torrents|data|incomplete|"") continue ;; esac if [ "${#b}" -ge 10 ]; then folder="$b" break fi done fi qbit_delete_by_folder_key "$folder" } arr_get() { local base="$1" key="$2" path="$3" curl -s --connect-timeout 10 -H "X-Api-Key: ${key}" "${base}${path}" 2>/dev/null || true } arr_history_hashes() { local base="$1" key="$2" kind="$3" id="$4" local endpoint hist if [ "$kind" = "movie" ]; then endpoint="/api/v3/history/movie?movieId=${id}" hist=$(arr_get "$base" "$key" "$endpoint") echo "$hist" | jq -r ' .. | objects | .downloadId? // .data.torrentInfoHash? // empty ' 2>/dev/null | tr 'A-F' 'a-f' | sort -u else endpoint="/api/v3/history?pageSize=250&seriesId=${id}" hist=$(arr_get "$base" "$key" "$endpoint") echo "$hist" | jq -r ' .records[]? | .downloadId // .data.torrentInfoHash // empty ' 2>/dev/null | tr 'A-F' 'a-f' | sort -u fi } # Active *arr queue download hashes (in-progress grabs). Hash is identity. arr_queue_hashes() { local base="$1" key="$2" kind="$3" id="$4" local endpoint raw if [ "$kind" = "movie" ]; then endpoint="/api/v3/queue?pageSize=250&movieId=${id}" raw=$(arr_get "$base" "$key" "$endpoint") echo "$raw" | jq -r --argjson id "$id" ' if type == "array" then .[] elif type == "object" then .records[]? else empty end | select((.movieId // empty) == ($id|tonumber)) | .downloadId // .data.torrentInfoHash // empty ' 2>/dev/null | tr 'A-F' 'a-f' | sort -u else endpoint="/api/v3/queue?pageSize=250&seriesId=${id}" raw=$(arr_get "$base" "$key" "$endpoint") echo "$raw" | jq -r --argjson id "$id" ' if type == "array" then .[] elif type == "object" then .records[]? else empty end | select((.seriesId // empty) == ($id|tonumber)) | .downloadId // .data.torrentInfoHash // empty ' 2>/dev/null | tr 'A-F' 'a-f' | sort -u fi } arr_queue_hashes_seasons() { local base="$1" key="$2" id="$3" seasons_json="$4" local raw raw=$(arr_get "$base" "$key" "/api/v3/queue?pageSize=250&seriesId=${id}") echo "$raw" | jq -r --argjson id "$id" --argjson seasons "$seasons_json" ' if type == "array" then .[] elif type == "object" then .records[]? else empty end | select((.seriesId // empty) == ($id|tonumber)) | . as $r | ( $r.episode.seasonNumber // $r.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 } # 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)" if [ "$DRY_RUN" = "1" ]; then log "DRY_RUN"; return 0; fi local code code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 15 -X DELETE \ -H "X-Api-Key: ${key}" \ "${RADARR_URL}/api/v3/movie/${id}?deleteFiles=true&addImportExclusion=false" 2>/dev/null || echo "000") log "Radarr: HTTP ${code}" if [ "$code" = "200" ] || [ "$code" = "204" ]; then PURGE_TOUCHED=1 fi } arr_delete_series() { local key="$1" id="$2" log "Sonarr: DELETE series id=${id} (deleteFiles=true)" if [ "$DRY_RUN" = "1" ]; then log "DRY_RUN"; return 0; fi local code code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 15 -X DELETE \ -H "X-Api-Key: ${key}" \ "${SONARR_URL}/api/v3/series/${id}?deleteFiles=true&addImportListExclusion=false" 2>/dev/null || echo "000") log "Sonarr: HTTP ${code}" if [ "$code" = "200" ] || [ "$code" = "204" ]; then PURGE_TOUCHED=1 fi } # Remaining seasons from Sonarr series + episodefile JSON (includes specials / 0). # Used to decide series delete vs season-scoped delete. seasons_remaining_json() { local series="${1:-}" local files="${2:-}" local out [ -n "$series" ] || series='{}' [ -n "$files" ] || files='[]' out=$(jq -nc --argjson series "$series" --argjson files "$files" ' ([ $files[]? | select(.seasonNumber != null) | .seasonNumber ] | unique) as $on_disk | ([ $series.seasons[]? | select(.monitored == true) | .seasonNumber ] | unique) as $mon | ($on_disk + $mon) | unique | sort ' 2>/dev/null) || out='[]' [ -n "$out" ] || out='[]' printf '%s\n' "$out" } # True when this season list covers every remaining on-disk / monitored season # (specials/0 count; deleting S01 must not wipe Specials). seasons_list_covers_remaining() { local seasons_json="$1" local remain_json="$2" jq -en --argjson seasons "$seasons_json" --argjson remain "$remain_json" ' ($remain | length) > 0 and (($remain - $seasons) | length) == 0 ' >/dev/null 2>&1 } seasons_cover_remaining() { local key="$1" id="$2" seasons_json="$3" local series files remain series=$(arr_get "$SONARR_URL" "$key" "/api/v3/series/${id}") files=$(arr_get "$SONARR_URL" "$key" "/api/v3/episodefile?seriesId=${id}") remain=$(seasons_remaining_json "$series" "${files:-[]}") seasons_list_covers_remaining "$seasons_json" "$remain" } 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" local b json b=$(basename_of "$path") case "$b" in movies|tv|media|torrents|incomplete|data|""|.) return 0 ;; esac if [ "$kind" = "movie" ]; then json=$(arr_get "$base" "$key" "/api/v3/movie") echo "$json" | jq -r --arg p "$path" --arg b "$b" ' .[] | select( (.path // "") == $p or ((.path // "") == ($p | sub("/$"; ""))) or ((.path // "") | endswith("/" + $b)) ) | .id ' 2>/dev/null | head -1 else json=$(arr_get "$base" "$key" "/api/v3/series") echo "$json" | jq -r --arg p "$path" --arg b "$b" ' .[] | select( (.path // "") == $p or ((.path // "") == ($p | sub("/$"; ""))) or ((.path // "") | endswith("/" + $b)) ) | .id ' 2>/dev/null | head -1 fi } purge_qb() { local kind="$1" base_url="$2" key="$3" id="$4" title="$5" path="$6" local 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:-} seasons=${seasons_json:-}" if [ -n "$seasons_json" ] && [ "$seasons_json" != "[]" ]; then while IFS= read -r h; do [ -n "$h" ] && hlist+=("$h") done < <(arr_queue_hashes_seasons "$base_url" "$key" "$id" "$seasons_json") 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_queue_hashes "$base_url" "$key" "$kind" "$id") 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 # Unique, keep order hlist=($(printf '%s\n' "${hlist[@]}" | awk 'NF && !seen[$0]++')) fi if [ "${#hlist[@]}" -gt 0 ]; then log "${kind} queue/history candidates: ${hlist[*]}" info=$(qbit_torrents_info) while IFS= read -r h; do [ -n "$h" ] && filtered+=("$h") done < <(echo "$info" | qbit_filter_hashes_jq "${hlist[@]}" 2>/dev/null || true) if [ "${#filtered[@]}" -gt 0 ]; then log "qB: queue/history hashes in qB (${#filtered[@]}): ${filtered[*]}" qbit_delete_hashes "${filtered[@]}" remove_orphan_torrent_dirs "$folder" "$seasons_json" else log "qB: queue/history hashes not in qB; trying title+year match" fi else log "no queue/history hashes for ${kind} ${id}" fi if [ -n "$folder" ]; then 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 name fallback (no safe folder key)" fi } purge_qb_for_movie() { purge_qb movie "$RADARR_URL" "$1" "$2" "$3" "$4" } purge_qb_for_series() { purge_qb series "$SONARR_URL" "$1" "$2" "$3" "$4" "${5:-}" } # Best-effort library refresh so deletes do not leave ghost items. notify_jellyfin() { if [ "$SKIP_JELLYFIN" = "1" ] || [ "$DRY_RUN" = "1" ]; then return 0 fi if [ "$PURGE_TOUCHED" != "1" ]; then return 0 fi # Only meaningful for Jellyfin (and Emby-compatible token header). case "${MEDIA_SERVICE:-jellyfin}" in jellyfin|emby|"") ;; *) log "media server ${MEDIA_SERVICE}: skip library refresh"; return 0 ;; esac local key="" url code if [ -n "${JELLYFIN_API_KEY:-}" ]; then key="$JELLYFIN_API_KEY" elif [ -f "${JELLYFIN_API_KEY_FILE:-/run/secrets/jellyfin_api_key.txt}" ]; then key=$(read_secret_file "${JELLYFIN_API_KEY_FILE:-/run/secrets/jellyfin_api_key.txt}" 2>/dev/null || true) elif [ -n "${CONFIG_DIR:-}" ] && [ -f "${CONFIG_DIR}/../secrets/jellyfin_api_key.txt" ]; then key=$(read_secret_file "${CONFIG_DIR}/../secrets/jellyfin_api_key.txt" 2>/dev/null || true) elif [ -n "${INSTALL_DIR:-}" ] && [ -f "${INSTALL_DIR}/secrets/jellyfin_api_key.txt" ]; then key=$(read_secret_file "${INSTALL_DIR}/secrets/jellyfin_api_key.txt" 2>/dev/null || true) fi if [ -z "$key" ]; then log "Jellyfin: no API key; skip library refresh" return 0 fi url="${JELLYFIN_URL%/}/Library/Refresh" code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 8 \ -X POST -H "X-Emby-Token: ${key}" "$url" 2>/dev/null || echo "000") if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then log "Jellyfin: library refresh triggered (HTTP ${code})" else log "Jellyfin: library refresh failed (HTTP ${code})" fi } # Offline matcher self-test (no Docker / qB): media-purge.sh --match-self-test if [ "${1:-}" = "--match-self-test" ]; then failures=0 check() { local want="$1" name="$2" tokens="$3" torrents="$4" local got got=$(echo "$torrents" | qb_match_jq "$tokens" | tr '\n' ' ' | sed 's/[[:space:]]*$//') if [ "$got" = "$want" ]; then echo " PASS: $name" else echo " FAIL: $name (want='$want' got='$got')" failures=$((failures + 1)) fi } check_filter() { local want="$1" name="$2" torrents="$3" shift 3 local got got=$(echo "$torrents" | qbit_filter_hashes_jq "$@" | tr '\n' ' ' | sed 's/[[:space:]]*$//') if [ "$got" = "$want" ]; then echo " PASS: $name" else echo " FAIL: $name (want='$want' got='$got')" failures=$((failures + 1)) fi } echo "=== media-purge qB match self-test ===" # Synthetic titles only — prove match rules, not real catalog entries. check "hash_a" "year folder hits only first film" \ '["Alpha Film (2016)"]' \ '[{"hash":"hash_a","name":"Alpha Film (2016) [GRP]","content_path":"/data/torrents/movies/Alpha Film (2016)"},{"hash":"hash_b","name":"Alpha Film Sequel Extended (2022)","content_path":"/data/torrents/movies/Alpha Film Sequel Extended (2022)"}]' check "" "bare short title does not hit longer sequel name" \ '["Alpha Film"]' \ '[{"hash":"hash_b","name":"Alpha Film Sequel Extended (2022)","content_path":"/data/torrents/movies/Alpha Film Sequel Extended (2022)"}]' check "hash_a" "bare short title hits exact name only" \ '["Alpha Film"]' \ '[{"hash":"hash_a","name":"Alpha Film","content_path":"/data/torrents/movies/Alpha Film"},{"hash":"hash_b","name":"Alpha Film Sequel Extended","content_path":"/data/torrents/movies/Alpha Film Sequel Extended"}]' check "hash_y" "year preferred when bare+year present" \ '["Sample Title","Sample Title (2008)"]' \ '[{"hash":"hash_y","name":"Sample Title (2008) [e2e]","content_path":"/data/torrents/movies/Sample Title (2008) [e2e]"},{"hash":"hash_z","name":"Sample Title Adventures","content_path":"/data/torrents/movies/Sample Title Adventures"}]' check "hash_m1" "bare title does not hit same-prefix sequel" \ '["Beta Movie"]' \ '[{"hash":"hash_m1","name":"Beta Movie","content_path":"/data/torrents/movies/Beta Movie"},{"hash":"hash_m2","name":"Beta Movie Reloaded","content_path":"/data/torrents/movies/Beta Movie Reloaded"}]' check_filter "hash_a" "history filter keeps live hash even when name is a release" \ '[{"hash":"hash_a","name":"The Avengers 2012 REPACK BluRay","content_path":"/data/torrents/incomplete/movies/The Avengers 2012 REPACK BluRay"},{"hash":"hash_b","name":"Other Title","content_path":"/data/torrents/movies/Other"}]' \ hash_a check_filter "hash_a" "history filter keeps every live candidate hash" \ '[{"hash":"hash_a","name":"Alpha Film Sequel Extended (2022)","content_path":"/data/torrents/movies/Alpha Film Sequel Extended (2022)"}]' \ hash_a hash_b check_filter "" "history filter drops hashes not in qB" \ '[{"hash":"hash_b","name":"Other","content_path":"/x"}]' \ hash_a check "hash_av" "title+year hits release name without parens" \ '["The Avengers (2012)"]' \ '[{"hash":"hash_av","name":"The Avengers 2012 REPACK BluRay 1080p DDP 5 1 x264-hallowed","content_path":"/data/torrents/incomplete/movies/www.UIndex.org - The Avengers 2012 REPACK BluRay 1080p DDP 5 1 x264-hallowed"}]' check "hash_av" "title+year hits indexer-prefixed incomplete path" \ '["The Avengers (2012)"]' \ '[{"hash":"hash_av","name":"www.UIndex.org - The Avengers 2012 REPACK","content_path":"/data/torrents/incomplete/movies/www.UIndex.org - The Avengers 2012 REPACK"}]' check "" "title+year does not hit Age of Ultron" \ '["The Avengers (2012)"]' \ '[{"hash":"hash_u","name":"Avengers Age of Ultron 2015 1080p","content_path":"/data/torrents/movies/Avengers Age of Ultron 2015 1080p"}]' check "" "short It (2017) does not hit It Comes at Night" \ '["It (2017)"]' \ '[{"hash":"hash_n","name":"It Comes at Night 2017 1080p","content_path":"/data/torrents/movies/It Comes at Night 2017"}]' check "hash_it" "It (2017) hits It 2017 release" \ '["It (2017)"]' \ '[{"hash":"hash_it","name":"It 2017 1080p BluRay","content_path":"/data/torrents/movies/It 2017 1080p BluRay"}]' check "hash_bb" "article-stripped title still needs the year next" \ '["The Avengers (2012)"]' \ '[{"hash":"hash_bb","name":"Avengers 2012 1080p","content_path":"/data/torrents/movies/Avengers 2012 1080p"}]' 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"}]' check "hash_s2" "title-level Loki matches S02 complete pack" \ '["Loki"]' \ '[{"hash":"hash_s2","name":"Loki.S02.COMPLETE.1080p.DSNP.WEB-DL","content_path":"/data/torrents/tv/Loki.S02.COMPLETE.1080p.DSNP.WEB-DL"}]' check "" "title-level Lost still does not match Lost in Space pack" \ '["Lost"]' \ '[{"hash":"hash_lis","name":"Lost in Space.S01.COMPLETE","content_path":"/data/torrents/tv/Lost in Space.S01.COMPLETE"}]' echo "=== media-purge remaining-season cover ===" cover_check() { local want="$1" name="$2" seasons="$3" series="$4" files="$5" local remain got remain=$(seasons_remaining_json "$series" "$files") if seasons_list_covers_remaining "$seasons" "$remain"; then got="cover" else got="partial" fi if [ "$got" = "$want" ]; then echo " PASS: $name" else echo " FAIL: $name (want='$want' got='$got' remain='$remain')" failures=$((failures + 1)) fi } _series_s01s02='{"seasons":[{"seasonNumber":1,"monitored":true},{"seasonNumber":2,"monitored":true}]}' _files_s01s02='[{"seasonNumber":1},{"seasonNumber":2}]' _series_s01_spec='{"seasons":[{"seasonNumber":0,"monitored":true},{"seasonNumber":1,"monitored":true}]}' _files_s01_spec='[{"seasonNumber":0},{"seasonNumber":1}]' _series_spec_only='{"seasons":[{"seasonNumber":0,"monitored":true}]}' _files_spec_only='[{"seasonNumber":0}]' cover_check "partial" "S01 request does not cover S01+S02" \ '[1]' "$_series_s01s02" "$_files_s01s02" cover_check "cover" "S01+S02 request covers S01+S02" \ '[1,2]' "$_series_s01s02" "$_files_s01s02" cover_check "partial" "S01 request does not cover S01+specials" \ '[1]' "$_series_s01_spec" "$_files_s01_spec" cover_check "cover" "S01+specials request covers both" \ '[0,1]' "$_series_s01_spec" "$_files_s01_spec" cover_check "partial" "S01 request does not cover specials-only leftover" \ '[1]' "$_series_spec_only" "$_files_spec_only" cover_check "cover" "specials request covers specials-only leftover" \ '[0]' "$_series_spec_only" "$_files_spec_only" echo "=== media-purge leftover download dirs ===" _old_root="${MEDIA_ROOT:-}" MEDIA_ROOT=$(mktemp -d) mkdir -p "$MEDIA_ROOT/torrents/tv/Loki.S02.COMPLETE.1080p.DSNP.WEB-DL" \ "$MEDIA_ROOT/torrents/tv/Lost in Space.S01.COMPLETE" \ "$MEDIA_ROOT/torrents/incomplete/movies/www.UIndex.org - The Avengers 2012 REPACK BluRay" \ "$MEDIA_ROOT/torrents/movies/Avengers Age of Ultron 2015 1080p" DRY_RUN=0 remove_orphan_torrent_dirs "Loki" if [ ! -d "$MEDIA_ROOT/torrents/tv/Loki.S02.COMPLETE.1080p.DSNP.WEB-DL" ]; then echo " PASS: Loki leftover pack dir removed" else echo " FAIL: Loki leftover pack dir still present" failures=$((failures + 1)) fi if [ -d "$MEDIA_ROOT/torrents/tv/Lost in Space.S01.COMPLETE" ]; then echo " PASS: Lost in Space leftover left alone" else echo " FAIL: Lost in Space leftover was removed" failures=$((failures + 1)) fi remove_orphan_torrent_dirs "The Avengers (2012)" if [ ! -d "$MEDIA_ROOT/torrents/incomplete/movies/www.UIndex.org - The Avengers 2012 REPACK BluRay" ]; then echo " PASS: Avengers incomplete leftover removed" else echo " FAIL: Avengers incomplete leftover still present" failures=$((failures + 1)) fi if [ -d "$MEDIA_ROOT/torrents/movies/Avengers Age of Ultron 2015 1080p" ]; then echo " PASS: Age of Ultron leftover left alone" else echo " FAIL: Age of Ultron leftover was removed" failures=$((failures + 1)) fi rm -rf "$MEDIA_ROOT" MEDIA_ROOT="${_old_root}" if [ "$failures" -gt 0 ]; then echo "match-self-test: FAILED ($failures)" exit 1 fi echo "match-self-test: OK" exit 0 fi # --- args --- while [ $# -gt 0 ]; do case "$1" in --movie-id) MOVIE_ID="$2"; MODE=movie; shift 2 ;; --series-id) SERIES_ID="$2"; MODE=series; shift 2 ;; --path) PATH_ARG="$2"; MODE="${MODE:-path}"; shift 2 ;; --tmdb) TMDB_ID="$2"; MODE=tmdb; shift 2 ;; --tvdb) TVDB_ID="$2"; MODE=tvdb; shift 2 ;; --seasons) SEASONS="$2"; shift 2 ;; --radarr-event) MODE=radarr-event; shift ;; --sonarr-event) MODE=sonarr-event; shift ;; --qb-only) QB_ONLY=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --skip-jellyfin) SKIP_JELLYFIN=1; shift ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; *) die "unknown arg: $1" ;; esac done RADARR_API_KEY=$(discover_api_key radarr "${RADARR_API_KEY:-}") || RADARR_API_KEY="" SONARR_API_KEY=$(discover_api_key sonarr "${SONARR_API_KEY:-}") || SONARR_API_KEY="" case "$MODE" in radarr-event) et="${radarr_eventtype:-}" log "radarr event type=${et}" case "$et" in Test) log "Test ok"; exit 0 ;; # Movie-level only: file deletes are partial and must not wipe whole qB items by title. MovieDelete) ;; MovieFileDelete) log "ignore MovieFileDelete (movie-level purge only)" exit 0 ;; *) log "ignore ${et}"; exit 0 ;; esac mid="${radarr_movie_id:-}" title="${radarr_movie_title:-}" path="${radarr_movie_path:-}" if [ -n "$mid" ] && [ -n "$RADARR_API_KEY" ]; then purge_qb_for_movie "$RADARR_API_KEY" "$mid" "$title" "$path" else qbit_delete_by_folder_key "$(folder_key_from "$path" "$title")" fi # Files already removed by Radarr; still refresh Jellyfin. PURGE_TOUCHED=1 notify_jellyfin exit 0 ;; sonarr-event) et="${sonarr_eventtype:-}" log "sonarr event type=${et}" case "$et" in Test) log "Test ok"; exit 0 ;; SeriesDelete) ;; EpisodeFileDelete) log "ignore EpisodeFileDelete (series-level purge only)" exit 0 ;; *) log "ignore ${et}"; exit 0 ;; esac sid="${sonarr_series_id:-}" title="${sonarr_series_title:-}" path="${sonarr_series_path:-}" if [ -n "$sid" ] && [ -n "$SONARR_API_KEY" ]; then purge_qb_for_series "$SONARR_API_KEY" "$sid" "$title" "$path" else qbit_delete_by_folder_key "$(folder_key_from "$path" "$title")" fi PURGE_TOUCHED=1 notify_jellyfin exit 0 ;; movie) [ -n "$MOVIE_ID" ] || die "--movie-id required" [ -n "$RADARR_API_KEY" ] || die "Radarr API key missing" movie=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie/${MOVIE_ID}") title=$(echo "$movie" | jq -r '.title // empty') path=$(echo "$movie" | jq -r '.path // empty') # qB first (history still available), then *arr delete purge_qb_for_movie "$RADARR_API_KEY" "$MOVIE_ID" "$title" "$path" if [ "$QB_ONLY" != "1" ]; then arr_delete_movie "$RADARR_API_KEY" "$MOVIE_ID" fi ;; series) [ -n "$SERIES_ID" ] || die "--series-id required" [ -n "$SONARR_API_KEY" ] || die "Sonarr API key missing" series=$(arr_get "$SONARR_URL" "$SONARR_API_KEY" "/api/v3/series/${SERIES_ID}") title=$(echo "$series" | jq -r '.title // empty') path=$(echo "$series" | jq -r '.path // empty') 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) [ -n "$PATH_ARG" ] || die "--path required" path="$PATH_ARG" base=$(basename_of "$path") log "purge by path=${path}" if [ "$QB_ONLY" != "1" ] && [ -n "$RADARR_API_KEY" ]; then mid=$(arr_id_for_path "$RADARR_URL" "$RADARR_API_KEY" movie "$path") if [ -n "$mid" ]; then movie=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie/${mid}") title=$(echo "$movie" | jq -r '.title // empty') mpath=$(echo "$movie" | jq -r '.path // empty') purge_qb_for_movie "$RADARR_API_KEY" "$mid" "$title" "$mpath" arr_delete_movie "$RADARR_API_KEY" "$mid" notify_jellyfin log "done" exit 0 fi fi if [ "$QB_ONLY" != "1" ] && [ -n "$SONARR_API_KEY" ]; then series_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') 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 fi fi log "no *arr row; folder-key qB cleanup only" 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" [ -n "$RADARR_API_KEY" ] || die "Radarr API key missing" movies=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie") mid=$(echo "$movies" | jq -r --argjson t "$TMDB_ID" '.[] | select(.tmdbId == $t) | .id' | head -1) [ -n "$mid" ] || die "no Radarr movie tmdbId=${TMDB_ID}" exec "$0" --movie-id "$mid" ;; tvdb) [ -n "$TVDB_ID" ] || die "--tvdb required" [ -n "$SONARR_API_KEY" ] || die "Sonarr API key missing" series_json=$(arr_get "$SONARR_URL" "$SONARR_API_KEY" "/api/v3/series") sid=$(echo "$series_json" | jq -r --argjson t "$TVDB_ID" '.[] | select(.tvdbId == $t) | .id' | head -1) [ -n "$sid" ] || die "no Sonarr series tvdbId=${TVDB_ID}" if [ -n "$SEASONS" ]; then exec "$0" --series-id "$sid" --seasons "$SEASONS" fi exec "$0" --series-id "$sid" ;; *) die "specify --movie-id, --series-id, --path, --tmdb, --tvdb, --radarr-event, or --sonarr-event" ;; esac if [ "$QB_ONLY" != "1" ]; then notify_jellyfin fi log "done"