From 9ac07caf12cdc952dda41a2e06d42d97ff8cdf41 Mon Sep 17 00:00:00 2001 From: soulis-1256 Date: Sun, 16 Aug 2026 12:04:27 +0300 Subject: [PATCH] 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. --- README.md | 4 +- bin/setup.sh | 2 +- compose/base.yaml | 7 +- docs/seerr-delete-request.md | 73 +++- lib/arr.sh | 6 +- scripts/media-purge-watch.sh | 81 +++- scripts/media-purge.sh | 380 ++++++++++++++++- scripts/seerr-gateway.py | 610 +++++++++++++++++++++++++-- tests/README.md | 2 +- tests/unit/test_media_purge_watch.sh | 7 + tests/unit/test_seerr_gateway.sh | 3 + 11 files changed, 1105 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 543fdea..7021284 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Self-hosted media automation, set up in minutes, not a weekend. - **Managers:** Sonarr, Radarr - **Subtitles:** Bazarr (auto-wired to Sonarr/Radarr; optional OpenSubtitles.com) - **Indexing:** Prowlarr -- **Request Management:** Seerr (delete request fully removes media via `seerr-gateway`; see [docs/seerr-delete-request.md](docs/seerr-delete-request.md)) +- **Request Management:** Seerr (delete request removes that request’s media via `seerr-gateway`; TV is season-scoped — see [docs/seerr-delete-request.md](docs/seerr-delete-request.md)) - **Quality Profiles:** Recyclarr - **Management:** Built-in `assemblrr` CLI (`status`, `logs`, `start`/`stop`, …) @@ -93,7 +93,7 @@ Configuration lives in your installation directory (default: `~/assemblrr`). ## Documentation - **[TRaSH Guides & Quality Profiles](docs/trash-guides.md):** Details on how assemblrr handles quality profiles and what to do when community guide IDs change. -- **[Seerr delete request → full purge](docs/seerr-delete-request.md):** How `seerr-gateway` makes “delete request” remove media/torrents, and how to retire it if Seerr gains this natively. +- **[Seerr delete request → purge](docs/seerr-delete-request.md):** How `seerr-gateway` makes “delete request” remove media/torrents (TV is season-scoped), and how to retire it if Seerr gains this natively. - **[Uninstall Guide](docs/uninstall.md):** Complete removal guide, including a full inventory of what an installation creates. - **[API Reference](docs/api-reference.md):** Official API documentation links for all services in the stack. diff --git a/bin/setup.sh b/bin/setup.sh index 7885e58..39423f3 100644 --- a/bin/setup.sh +++ b/bin/setup.sh @@ -92,7 +92,7 @@ fi readonly SUPPORTED_MEDIA_SERVICES=("jellyfin" "emby" "plex") readonly DEFAULT_MEDIA_SERVICE="jellyfin" readonly DEFAULT_VPN_SERVICE="protonvpn" -readonly MEDIA_SUBDIRS=("torrents/movies" "torrents/tv" "media/movies" "media/tv" "blackhole") +readonly MEDIA_SUBDIRS=("torrents/movies" "torrents/tv" "media/movies" "media/tv" "media/.recycle" "blackhole") # Dependencies readonly REQUIRED_COMMANDS=("curl" "jq" "sed" "awk" "envsubst" "fzf") diff --git a/compose/base.yaml b/compose/base.yaml index 27448d1..2493e3c 100644 --- a/compose/base.yaml +++ b/compose/base.yaml @@ -201,7 +201,8 @@ services: assemblrr_network: ipv4_address: 172.60.0.17 - # Thin reverse proxy in front of Seerr: pass-through + delete-request full purge. + # Thin reverse proxy in front of Seerr: pass-through + delete-request purge. + # TV: season-scoped via Sonarr when the request is a subset of remaining seasons. # SEERR_DELETE_REQUEST_PURGE=0 disables intercept (stock Seerr semantics). seerr-gateway: image: python:3.12-alpine @@ -212,9 +213,13 @@ services: - SEERR_GATEWAY_LISTEN=0.0.0.0 - SEERR_GATEWAY_PORT=5055 - SEERR_DELETE_REQUEST_PURGE=${SEERR_DELETE_REQUEST_PURGE:-1} + - SONARR_URL=http://sonarr:8989 + - SONARR_CONFIG=/config/sonarr/config.xml volumes: - /etc/localtime:/etc/localtime:ro - ${INSTALL_DIRECTORY}/scripts/seerr-gateway.py:/scripts/seerr-gateway.py:ro + # Bind the dir, not config.xml — Docker creates a directory if the file is missing. + - ${INSTALL_DIRECTORY}/config/sonarr:/config/sonarr:ro ports: - 5055:5055 depends_on: diff --git a/docs/seerr-delete-request.md b/docs/seerr-delete-request.md index 67de87a..040f140 100644 --- a/docs/seerr-delete-request.md +++ b/docs/seerr-delete-request.md @@ -1,12 +1,16 @@ -# Seerr “Delete request” → full media purge +# Seerr “Delete request” → media purge assemblrr product intent: when a user deletes a Seerr **request**, the stack -should also remove the title from Radarr/Sonarr, library files under `media/`, -and the qBittorrent torrent/data under `torrents/` (hardlink-aware). +should also remove **that request’s** library files under `media/` and the +matching qBittorrent torrent/data under `torrents/` (hardlink-aware). + +- **Movies:** the whole title. +- **TV:** only the seasons on that request. Other seasons of the same show stay. Stock Seerr does **not** do that. `DELETE /api/v1/request/:id` only removes the -request row. The path that frees library files is +request row. The path that frees an entire title is `DELETE /api/v1/media/:id/file` (Seerr calls *arr with `deleteFiles: true`). +That API is **title-scoped**. It cannot delete one season. ## What assemblrr does @@ -15,18 +19,42 @@ listens only on the Docker network. Almost all traffic is proxied unchanged. On **`DELETE /api/v1/request/:id`** only (when purge is enabled): -1. `GET /api/v1/request/:id` — capture linked `media.id` and `is4k` (same auth headers as the client) -2. `DELETE /api/v1/media/:mediaId/file?is4k=…` — Seerr → *arr delete with files → assemblrr purge hooks (qB) -3. `DELETE /api/v1/request/:id` — drop the request row (response returned to the client) +1. `GET /api/v1/request/:id` — capture linked `media.id`, `is4k`, type, and + requested season numbers (same auth headers as the client). +2. Purge files: + - **Movie**, or a TV request that covers every remaining on-disk/monitored + season → `DELETE /api/v1/media/:mediaId/file?is4k=…` (Seerr → *arr + `deleteFiles` → assemblrr purge hooks / qB). + - **TV subset of seasons** → Sonarr `DELETE /episodefile/{id}` for those + seasons only, then unmonitor them. Seerr title-level file-delete is + **not** called, so `/data/media/tv/Show` is not wiped. `media-purge-watch` + then season-scopes qB cleanup when that season folder empties. +3. `DELETE /api/v1/request/:id` — drop the request row (response returned to + the client). If media was never linked, step 2 is skipped; the request is still deleted. Auth failures on step 1 are returned to the client with no deletes. +If a TV request has no season list, the Sonarr API key is missing, or a +season-scoped Sonarr call fails, the gateway returns **502** and does +**not** delete the request (it never title-deletes on uncertainty). | Piece | Role | -|-------|------| -| `scripts/seerr-gateway.py` | Reverse proxy + request-delete intercept | -| `compose/base.yaml` → `seerr-gateway` | Publishes 5055; `seerr` is internal only | -| `scripts/arr-purge-hook.sh` / `media-purge.sh` | qB + disk cleanup after *arr delete; then best-effort Jellyfin library refresh | +|------|------| +| `scripts/seerr-gateway.py` | Reverse proxy + request-delete intercept (season-aware for TV) | +| `compose/base.yaml` → `seerr-gateway` | Publishes 5055; `seerr` is internal only; reads Sonarr API key | +| `scripts/arr-purge-hook.sh` / `media-purge.sh` | qB + disk cleanup; `--seasons` for partial TV | +| `scripts/media-purge-watch.sh` | inotify: empty season folder → `--seasons` purge (even if other seasons are only monitored/downloading); movie / series-root delete → title purge | + +### Title-level delete in Seerr (not season-safe) + +Seerr’s own **bulk delete / “remove from library” / delete files** on a TV +card is `DELETE /api/v1/media/:id/file`. That always removes the **entire +series** from Sonarr (`deleteFiles: true`). assemblrr does **not** intercept +that path: it means “this title”. + +A recycle bin at `/data/media/.recycle` (Sonarr/Radarr, 7-day cleanup) makes a +mistaken title delete recoverable for library files. qB torrents may still be +removed by the series-delete hook. ### Purge safety (qB) @@ -35,6 +63,12 @@ Auth failures on step 1 are returned to the client with no deletes. 1. *arr history download hashes that still exist in qB **and** match the library folder key (`Title (YYYY)`) 2. Exact folder-key match on torrent name / content path (no bare short-title prefix) 3. Refuse ambiguous multi-matches +4. **Season purge:** torrent must name one of the requested seasons (`S01` / + `Season 1`) and must **not** also name another season. Packs like + `Show.S01.S02.COMPLETE` and ranges like `Show.S01-03` are left alone + when deleting S01 only. A torrent with no season token is left alone. + Folder-key matching uses the same title-boundary rules as title-level + (short keys such as `Oz` are skipped). See `media-purge.sh --match-self-test`. @@ -45,11 +79,14 @@ Env: | `SEERR_DELETE_REQUEST_PURGE` | `1` | `1` = intercept delete-request; `0`/`false`/`off` = pure passthrough (stock Seerr) | | `MEDIA_PURGE_WATCH` | `1` | `1` = inotify watcher on library video deletes; `0` = disable watcher only | | `SEERR_UPSTREAM` | `http://seerr:5055` | Upstream Seerr (container) | +| `SONARR_URL` | `http://sonarr:8989` | Used by the gateway for TV season deletes | +| `SONARR_CONFIG` | `/config/sonarr/config.xml` | API key source when `SONARR_API_KEY` is unset | Debug response headers on intercepted deletes: - `X-Assemblrr-Request-Delete-Purge: 1` -- `X-Assemblrr-Purge-Detail: …` (e.g. `media_file_deleted:7:204`, `no_media`) +- `X-Assemblrr-Purge-Detail: …` (e.g. `media_file_deleted:7:204`, + `seasons_deleted:6:10:1:3`, `no_media`) Health (gateway only, not Seerr): @@ -70,6 +107,7 @@ delete races, confusing semantics). Do the following: 1. **Confirm** upstream behavior in Seerr release notes / source: - Does `DELETE /api/v1/request/:id` call *arr with `deleteFiles`? - Is it always on, or a setting? + - Is it season-scoped for TV? 2. **Short term:** set `SEERR_DELETE_REQUEST_PURGE=0` in the install `.env` (or compose environment) and restart `seerr-gateway`. Traffic stays on 5055; intercept is off. Verify once that stock delete-request still meets product @@ -89,14 +127,19 @@ Track ownership: this file and `scripts/seerr-gateway.py` header comment. ## Tests ```bash -# Offline unit (mock Seerr): +# Offline unit (mock Seerr + mock Sonarr): python3 scripts/seerr-gateway.py --self-test # or ./tests/unit/test_seerr_gateway.sh +# qB season matcher + watch helpers: +./tests/unit/test_media_purge_match.sh +./tests/unit/test_media_purge_watch.sh + # Live (opt-in; real install): ASSEMBLRR_ALLOW_LIVE_TEST=1 ./tests/integration/media_purge_e2e.sh ``` -The live suite includes **delete request through the gateway** → full cascade, -and documents that stock Seerr request-delete alone does not wipe files. +The live suite includes **delete request through the gateway** → full cascade +for a **movie**, and documents that stock Seerr request-delete alone does not +wipe files. diff --git a/lib/arr.sh b/lib/arr.sh index ac65045..d1d5dd8 100644 --- a/lib/arr.sh +++ b/lib/arr.sh @@ -331,12 +331,14 @@ configure_arr_service() { mediamgmt=$(api_get "$port" "/api/v3/config/mediamanagement" "$apikey") if [ -n "$mediamgmt" ]; then local mgmt_payload - mgmt_payload=$(echo "$mediamgmt" | jq --arg uf "$unmonitor_field" '.copyUsingHardlinks = true | .enableMediaManagement = true | .[$uf] = true | .recycleBin = ""' 2>/dev/null || echo "") + mgmt_payload=$(echo "$mediamgmt" | jq --arg uf "$unmonitor_field" \ + '.copyUsingHardlinks = true | .enableMediaManagement = true | .[$uf] = true + | .recycleBin = "/data/media/.recycle" | .recycleBinCleanupDays = 7' 2>/dev/null || echo "") if [ -n "$mgmt_payload" ]; then local mgmt_result mgmt_result=$(api_put "$port" "/api/v3/config/mediamanagement" "$apikey" "$mgmt_payload") if jq_json_has_key "$mgmt_result" "id"; then - log_step "${service_name}: enabled hardlinks in Media Management" + log_step "${service_name}: enabled hardlinks + recycle bin (/data/media/.recycle)" else log_step_fail "${service_name}: failed to enable hardlinks" fi diff --git a/scripts/media-purge-watch.sh b/scripts/media-purge-watch.sh index b0a742d..dfa866d 100755 --- a/scripts/media-purge-watch.sh +++ b/scripts/media-purge-watch.sh @@ -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" } diff --git a/scripts/media-purge.sh b/scripts/media-purge.sh index 23675c2..3591325 100755 --- a/scripts/media-purge.sh +++ b/scripts/media-purge.sh @@ -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:-}" + log "qB purge kind=${kind} id=${id} folder_key=${folder:-} seasons=${seasons_json:-}" - 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" ;; *) diff --git a/scripts/seerr-gateway.py b/scripts/seerr-gateway.py index b75f795..20e258a 100755 --- a/scripts/seerr-gateway.py +++ b/scripts/seerr-gateway.py @@ -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"([^<]+)", 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: diff --git a/tests/README.md b/tests/README.md index 91dc633..5f90909 100644 --- a/tests/README.md +++ b/tests/README.md @@ -38,7 +38,7 @@ Requires a running stack with purge wiring (`media-purge-watch` up, `seerr-gatew 1. Creates a synthetic hardlinked title under `media/` + `torrents/` and a local qB torrent (no indexer grab). 2. **Radarr** `DELETE /api/v3/movie/:id?deleteFiles=true` → CustomScript → qB cleanup. 3. **Seerr** `DELETE /api/v1/media/:id/file` → Radarr delete → same cascade. -4. **Seerr** `DELETE /api/v1/request/:id` **through seerr-gateway** → media file delete + request gone → same cascade (checks `X-Assemblrr-Request-Delete-Purge`). +4. **Seerr** `DELETE /api/v1/request/:id` **through seerr-gateway** → movie/title purge or TV season-scoped purge + request gone (checks `X-Assemblrr-Request-Delete-Purge`). 5. **Jellyfin** `DELETE /Items/:id` → filesystem delete → `media-purge-watch` → full purge. 6. Asserts Radarr row, `media/`, `torrents/`, and qB torrent are all gone (other titles untouched). diff --git a/tests/unit/test_media_purge_watch.sh b/tests/unit/test_media_purge_watch.sh index ddea8c5..0ffe11f 100644 --- a/tests/unit/test_media_purge_watch.sh +++ b/tests/unit/test_media_purge_watch.sh @@ -33,4 +33,11 @@ assert_eq "/data/media/tv/Show Name" \ "series title dir" assert_eq "" "$(purge_watch_library_folder /data/torrents/movies/x.mkv)" "torrents path ignored" +test_suite "purge_watch_season_number" +assert_eq "2" "$(purge_watch_season_number '/data/media/tv/Show Name/Season 2/ep.mkv')" "Season 2 file" +assert_eq "1" "$(purge_watch_season_number '/data/media/tv/Show Name/Season 01/ep.mkv')" "Season 01 file" +assert_eq "0" "$(purge_watch_season_number '/data/media/tv/Show Name/Specials/ep.mkv')" "Specials" +assert_eq "" "$(purge_watch_season_number '/data/media/tv/Show Name/ep.mkv')" "no season folder" +assert_eq "" "$(purge_watch_season_number '/data/media/movies/Title (2012)/movie.mkv')" "movie path" + test_summary diff --git a/tests/unit/test_seerr_gateway.sh b/tests/unit/test_seerr_gateway.sh index 2c27624..6bfb32d 100755 --- a/tests/unit/test_seerr_gateway.sh +++ b/tests/unit/test_seerr_gateway.sh @@ -34,6 +34,9 @@ assert_not_contains "$seerr_block" "5055:5055" "seerr service does not publish h gw_block=$(awk '/^ seerr-gateway:/{p=1} p&&/^ [a-z]/{if(!/^ seerr-gateway:/)exit} p' "$base") assert_contains "$gw_block" "5055:5055" "seerr-gateway publishes 5055" assert_contains "$gw_block" "SEERR_DELETE_REQUEST_PURGE" "purge flag wired in compose" +assert_contains "$gw_block" "SONARR_URL" "gateway has Sonarr URL for TV season purge" +assert_contains "$gw_block" "config/sonarr:/config/sonarr" "gateway mounts Sonarr config dir" +assert_not_contains "$gw_block" "config.xml:/config/sonarr/config.xml" "gateway does not file-mount config.xml" # Catalog: Seerr UI is the gateway test_suite "service catalog points Seerr UI at gateway"