assemblrr/scripts/seerr-gateway.py

1736 lines
60 KiB
Python
Executable file

#!/usr/bin/env python3
"""
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):
* Movies (and TV requests that cover every remaining season): drain the
*arr download queue (removeFromClient) for this title first, then DELETE
/api/v1/media/:id/file → Radarr/Sonarr deleteFiles + purge hooks.
* TV requests for a subset of seasons: drain only those seasons' queue
items, then delete 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.
Retirement (when Seerr natively purges media on request delete):
- Set SEERR_DELETE_REQUEST_PURGE=0 for pass-through-only, or
- Remove this service and publish seerr:5055 again.
See docs/seerr-delete-request.md.
"""
from __future__ import annotations
import json
import logging
import os
import re
import socket
import sys
import threading
import time
import urllib.error
import urllib.request
from http.client import HTTPConnection, HTTPSConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
LISTEN_HOST = os.environ.get("SEERR_GATEWAY_LISTEN", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("SEERR_GATEWAY_PORT", "5055"))
UPSTREAM = os.environ.get("SEERR_UPSTREAM", "http://seerr:5055").rstrip("/")
# When "0"/"false"/"off": pure reverse proxy (no purge intercept).
PURGE_ON_DELETE_REQUEST = os.environ.get("SEERR_DELETE_REQUEST_PURGE", "1").strip().lower() not in (
"0",
"false",
"no",
"off",
)
CONNECT_TIMEOUT = float(os.environ.get("SEERR_GATEWAY_CONNECT_TIMEOUT", "10"))
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")
RADARR_URL = os.environ.get("RADARR_URL", "http://radarr:7878").rstrip("/")
RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "").strip()
RADARR_CONFIG = os.environ.get("RADARR_CONFIG", "/config/radarr/config.xml")
HOP_BY_HOP = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"proxy-connection",
}
log = logging.getLogger("seerr-gateway")
def configure_logging() -> None:
logging.basicConfig(
level=getattr(logging, LOG_LEVEL, logging.INFO),
format="seerr-gateway: %(levelname)s: %(message)s",
stream=sys.stderr,
)
def _header(headers: Dict[str, str], name: str) -> str:
want = name.lower()
for key, value in headers.items():
if key.lower() == want:
return value
return ""
def rewrite_location(value: str, client_host: str) -> str:
if not client_host or not value:
return value
_, host, port, _ = upstream_parts()
netlocs = [f"{host}:{port}" if port not in (80, 443) else host, "seerr:5055", "seerr"]
out = value
for netloc in netlocs:
out = out.replace(f"http://{netloc}", f"http://{client_host}")
out = out.replace(f"https://{netloc}", f"http://{client_host}")
return out
def filter_request_headers(headers: List[Tuple[str, str]], client_addr: str) -> Dict[str, str]:
out: Dict[str, str] = {}
incoming_host = ""
for key, value in headers:
lk = key.lower()
if lk in HOP_BY_HOP:
continue
if lk == "host":
incoming_host = value
continue
out[key] = value
# Browser Host so Seerr CSRF / redirects match what the user typed.
if incoming_host and not _header(out, "X-Forwarded-Host"):
out["X-Forwarded-Host"] = incoming_host
if not _header(out, "X-Forwarded-For"):
out["X-Forwarded-For"] = client_addr
if not _header(out, "X-Forwarded-Proto"):
out["X-Forwarded-Proto"] = "http"
return out
def filter_response_headers(
headers: List[Tuple[str, str]],
client_host: str = "",
) -> List[Tuple[str, str]]:
result: List[Tuple[str, str]] = []
for key, value in headers:
if key.lower() in HOP_BY_HOP:
continue
if key.lower() == "location":
value = rewrite_location(value, client_host)
result.append((key, value))
return result
def upstream_parts() -> Tuple[str, str, int, bool]:
parsed = urlparse(UPSTREAM if "://" in UPSTREAM else f"http://{UPSTREAM}")
scheme = parsed.scheme or "http"
host = parsed.hostname or "seerr"
port = parsed.port or (443 if scheme == "https" else 80)
return scheme, host, port, scheme == "https"
def open_upstream(method: str, path: str, headers: Dict[str, str], body: Optional[bytes]):
scheme, host, port, is_https = upstream_parts()
conn_cls = HTTPSConnection if is_https else HTTPConnection
conn = conn_cls(host, port, timeout=CONNECT_TIMEOUT)
hdrs = dict(headers)
# Pass the browser Host through (Seerr's own reverse-proxy docs). Fall back
# to the upstream name only when the client did not send one.
client_host = _header(hdrs, "X-Forwarded-Host")
if client_host:
hdrs["Host"] = client_host
else:
hdrs["Host"] = f"{host}:{port}" if port not in (80, 443) else host
if body is not None and "Content-Length" not in {k.title() for k in hdrs} and "content-length" not in {
k.lower() for k in hdrs
}:
hdrs["Content-Length"] = str(len(body))
conn.request(method, path, body=body, headers=hdrs)
# Apply read timeout on the socket after connect.
if conn.sock is not None:
conn.sock.settimeout(READ_TIMEOUT)
return conn, conn.getresponse()
def read_body(handler: BaseHTTPRequestHandler) -> bytes:
length = handler.headers.get("Content-Length")
if not length:
return b""
try:
n = int(length)
except ValueError:
return b""
if n <= 0:
return b""
return handler.rfile.read(n)
def json_response(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
handler.send_response(status)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
handler.end_headers()
if handler.command != "HEAD":
handler.wfile.write(body)
def proxy_once(
method: str,
path: str,
headers: Dict[str, str],
body: Optional[bytes],
) -> Tuple[int, List[Tuple[str, str]], bytes]:
conn = None
try:
conn, resp = open_upstream(method, path, headers, body)
resp_body = resp.read()
status = resp.status
resp_headers = filter_response_headers(resp.getheaders(), _header(headers, "X-Forwarded-Host"))
return status, resp_headers, resp_body
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
def parse_request_payload(data: bytes) -> dict:
if not data:
return {}
try:
return json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {}
def media_ids_from_request(payload: dict) -> Tuple[Optional[int], bool]:
"""Return (mediaId, is4k) from a Seerr MediaRequest payload."""
is4k = bool(payload.get("is4k", False))
media = payload.get("media") or payload.get("mediaInfo") or {}
if not isinstance(media, dict):
return None, is4k
mid = media.get("id")
if mid is None:
return None, is4k
try:
return int(mid), is4k
except (TypeError, ValueError):
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_arr_api_key(explicit: str, config_path: str) -> str:
if explicit:
return explicit
if not config_path or not os.path.isfile(config_path):
return ""
try:
text = open(config_path, encoding="utf-8").read()
except OSError:
return ""
m = re.search(r"<ApiKey>([^<]+)</ApiKey>", text)
return m.group(1).strip() if m else ""
def discover_sonarr_api_key() -> str:
return discover_arr_api_key(SONARR_API_KEY, SONARR_CONFIG)
def discover_radarr_api_key() -> str:
return discover_arr_api_key(RADARR_API_KEY, RADARR_CONFIG)
def _arr_request(
base_url: str,
method: str,
path: str,
api_key: str,
body: Optional[dict] = None,
timeout: float = 20,
label: str = "arr",
) -> Tuple[int, object]:
url = f"{base_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"{label} {method} {path}: {e}") from e
def _sonarr_request(
method: str,
path: str,
api_key: str,
body: Optional[dict] = None,
timeout: float = 20,
) -> Tuple[int, object]:
return _arr_request(SONARR_URL, method, path, api_key, body, timeout, "sonarr")
def _radarr_request(
method: str,
path: str,
api_key: str,
body: Optional[dict] = None,
timeout: float = 20,
) -> Tuple[int, object]:
return _arr_request(RADARR_URL, method, path, api_key, body, timeout, "radarr")
def request_media(payload: dict) -> dict:
media = payload.get("media") or payload.get("mediaInfo") or {}
return media if isinstance(media, dict) else {}
def arr_external_id(payload: dict, is4k: bool) -> Optional[int]:
media = request_media(payload)
raw = media.get("externalServiceId4k") if is4k else media.get("externalServiceId")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def radarr_lookup_movie_id(payload: dict, api_key: str) -> Optional[int]:
ext = arr_external_id(payload, bool(payload.get("is4k", False)))
if ext is not None:
return ext
tmdb = request_media(payload).get("tmdbId")
if tmdb is None:
return None
try:
tmdb_i = int(tmdb)
except (TypeError, ValueError):
return None
status, movies = _radarr_request("GET", "/api/v3/movie", api_key)
if status != 200 or not isinstance(movies, list):
return None
for row in movies:
if isinstance(row, dict) and row.get("tmdbId") == tmdb_i:
try:
return int(row["id"])
except (TypeError, ValueError, KeyError):
return None
return None
def queue_records(kind: str, arr_id: int, api_key: str) -> List[dict]:
if kind == "movie":
status, data = _radarr_request(
"GET", f"/api/v3/queue?pageSize=250&movieId={arr_id}", api_key
)
else:
status, data = _sonarr_request(
"GET", f"/api/v3/queue?pageSize=250&seriesId={arr_id}", api_key
)
if status != 200:
raise RuntimeError(f"{kind} queue GET HTTP {status}")
if isinstance(data, list):
rows = data
elif isinstance(data, dict):
rows = data.get("records") or []
else:
rows = []
return [r for r in rows if isinstance(r, dict)]
def queue_record_season(row: dict) -> Optional[int]:
if row.get("seasonNumber") is not None:
try:
return int(row["seasonNumber"])
except (TypeError, ValueError):
pass
ep = row.get("episode")
if isinstance(ep, dict) and ep.get("seasonNumber") is not None:
try:
return int(ep["seasonNumber"])
except (TypeError, ValueError):
return None
return None
def drain_arr_queue(
kind: str,
arr_id: int,
api_key: str,
seasons: Optional[List[int]] = None,
) -> Tuple[int, int]:
"""
Remove *arr queue items and the matching download-client torrents.
Returns (considered, deleted). Best-effort: HTTP failures on a single
item are logged and skipped.
"""
rows = queue_records(kind, arr_id, api_key)
wanted = set(seasons) if seasons else None
deleted = 0
considered = 0
req = _radarr_request if kind == "movie" else _sonarr_request
for row in rows:
if kind == "movie":
mid = row.get("movieId")
if mid is not None:
try:
if int(mid) != int(arr_id):
continue
except (TypeError, ValueError):
continue
elif kind == "series":
sid = row.get("seriesId")
if sid is not None:
try:
if int(sid) != int(arr_id):
continue
except (TypeError, ValueError):
continue
if wanted is not None:
sn = queue_record_season(row)
if sn is None or sn not in wanted:
continue
qid = row.get("id")
if qid is None:
continue
considered += 1
status, _ = req(
"DELETE",
f"/api/v3/queue/{qid}?removeFromClient=true&blocklist=false&skipRedownload=true",
api_key,
)
if status in (200, 204):
deleted += 1
else:
log.warning("%s queue delete id=%s HTTP %s", kind, qid, status)
return considered, deleted
def _drain_queue_note(
kind: str,
arr_id: int,
api_key: str,
seasons: Optional[List[int]],
request_id: str,
) -> str:
try:
considered, deleted = drain_arr_queue(kind, arr_id, api_key, seasons)
except Exception as exc:
log.warning("request %s: %s queue drain failed: %s", request_id, kind, exc)
return f"queue_{kind}_failed"
log.info(
"request %s: %s queue drain id=%s considered=%s deleted=%s",
request_id,
kind,
arr_id,
considered,
deleted,
)
return f"queue_{kind}:{considered}:{deleted}"
def _drain_movie_queue_note(payload: dict, request_id: str) -> str:
api_key = discover_radarr_api_key()
if not api_key:
log.info("request %s: no Radarr API key; skip queue drain", request_id)
return "queue_movie_skipped"
try:
movie_id = radarr_lookup_movie_id(payload, api_key)
except Exception as exc:
log.warning("request %s: radarr movie lookup failed: %s", request_id, exc)
return "queue_movie_failed"
if movie_id is None:
log.info("request %s: movie not in Radarr; skip queue drain", request_id)
return "queue_movie_missing"
return _drain_queue_note("movie", movie_id, api_key, None, request_id)
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 (includes specials / 0)."""
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 not isinstance(f, dict) or f.get("seasonNumber") is None:
continue
try:
remain.add(int(f["seasonNumber"]))
except (TypeError, ValueError):
continue
for s in series.get("seasons") or []:
if not isinstance(s, dict):
continue
try:
n = int(s.get("seasonNumber"))
except (TypeError, ValueError):
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,
headers: Dict[str, str],
) -> Tuple[int, List[Tuple[str, str]], bytes, str]:
"""
Cascade:
GET request
drain *arr queue (removeFromClient) while the title still exists
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). Queue drain is best-effort.
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)
elif get_status in (401, 403):
return get_status, [("Content-Type", "application/json")], get_body, "auth_failed"
else:
log.warning("GET /api/v1/request/%s → HTTP %s (continuing with request delete)", request_id, get_status)
purge_note = "no_media"
if media_id is not None:
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:
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):
qnote = _drain_queue_note(
"series", series_id, api_key, requested, request_id
)
deleted = sonarr_delete_seasons(series_id, requested, api_key)
purge_note = (
f"{qnote};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,
)
qnote = _drain_queue_note(
"series", series_id, api_key, None, request_id
)
pnote, _ = _delete_media_file(media_id, is4k, headers)
purge_note = f"{qnote};{pnote}"
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:
qnote = _drain_movie_queue_note(payload, request_id)
pnote, _ = _delete_media_file(media_id, is4k, headers)
purge_note = f"{qnote};{pnote}" if qnote else pnote
else:
log.info("request %s: no linked media id; deleting request only", request_id)
del_status, del_headers, del_body = proxy_once("DELETE", path, headers, None)
extra = list(del_headers)
extra.append(("X-Assemblrr-Request-Delete-Purge", "1"))
extra.append(("X-Assemblrr-Purge-Detail", purge_note[:200]))
return del_status, extra, del_body, purge_note
class GatewayHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "assemblrr-seerr-gateway/1.0"
def log_message(self, fmt: str, *args) -> None:
log.info("%s - %s", self.address_string(), fmt % args)
def log_error(self, fmt: str, *args) -> None:
log.error("%s - %s", self.address_string(), fmt % args)
def _client_ip(self) -> str:
return self.client_address[0] if self.client_address else "unknown"
def _forwarded_headers(self) -> Dict[str, str]:
return filter_request_headers(list(self.headers.items()), self._client_ip())
def do_GET(self) -> None: # noqa: N802
self._dispatch()
def do_HEAD(self) -> None: # noqa: N802
self._dispatch()
def do_POST(self) -> None: # noqa: N802
self._dispatch()
def do_PUT(self) -> None: # noqa: N802
self._dispatch()
def do_PATCH(self) -> None: # noqa: N802
self._dispatch()
def do_DELETE(self) -> None: # noqa: N802
self._dispatch()
def do_OPTIONS(self) -> None: # noqa: N802
self._dispatch()
def _dispatch(self) -> None:
path = self.path # includes query string
path_only = path.split("?", 1)[0]
if path_only in ("/_assemblrr/health", "/_assemblrr/ready"):
self._handle_health(ready=(path_only.endswith("/ready")))
return
if (
PURGE_ON_DELETE_REQUEST
and self.command == "DELETE"
and REQUEST_DELETE_RE.match(path_only)
):
m = REQUEST_DELETE_RE.match(path_only)
assert m is not None
request_id = m.group(1)
try:
status, headers, body, note = purge_then_delete_request(
request_id, path_only, self._forwarded_headers()
)
log.info(
"DELETE request/%s cascade done status=%s detail=%s",
request_id,
status,
note,
)
self._write_response(status, headers, body)
except Exception as exc:
log.exception("cascade failed for request %s: %s", request_id, exc)
json_response(
self,
502,
{"error": "seerr-gateway cascade failed", "detail": str(exc)},
)
return
# Pure reverse proxy (including DELETE request when purge disabled).
body = read_body(self) if self.command in ("POST", "PUT", "PATCH", "DELETE") else None
if self.command == "DELETE" and not PURGE_ON_DELETE_REQUEST and REQUEST_DELETE_RE.match(path_only):
log.info("DELETE request passthrough (SEERR_DELETE_REQUEST_PURGE disabled)")
try:
self._stream_proxy(self.command, path, self._forwarded_headers(), body)
except Exception as exc:
log.exception("proxy error %s %s: %s", self.command, path_only, exc)
json_response(self, 502, {"error": "seerr-gateway proxy failed", "detail": str(exc)})
def _handle_health(self, ready: bool) -> None:
upstream_ok = None
upstream_status = None
if ready:
try:
status, _, _ = proxy_once("GET", "/api/v1/settings/public", {}, None)
upstream_ok = 200 <= status < 300
upstream_status = status
except Exception as exc:
upstream_ok = False
upstream_status = str(exc)
if not upstream_ok:
json_response(
self,
503,
{
"ok": False,
"upstream": UPSTREAM,
"upstream_ok": False,
"upstream_status": upstream_status,
"purge_on_delete_request": PURGE_ON_DELETE_REQUEST,
},
)
return
json_response(
self,
200,
{
"ok": True,
"upstream": UPSTREAM,
"upstream_ok": upstream_ok,
"upstream_status": upstream_status,
"purge_on_delete_request": PURGE_ON_DELETE_REQUEST,
},
)
def _write_response(self, status: int, headers: List[Tuple[str, str]], body: bytes) -> None:
self.send_response(status)
# Avoid duplicate Content-Length from upstream when we re-send body.
sent_len = False
for key, value in headers:
if key.lower() == "content-length":
continue
self.send_header(key, value)
self.send_header("Content-Length", str(len(body)))
sent_len = True
assert sent_len
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _stream_proxy(
self,
method: str,
path: str,
headers: Dict[str, str],
body: Optional[bytes],
) -> None:
conn = None
try:
conn, resp = open_upstream(method, path, headers, body)
self.send_response(resp.status)
for key, value in filter_response_headers(
resp.getheaders(), _header(headers, "X-Forwarded-Host")
):
if key.lower() == "content-length":
continue
self.send_header(key, value)
# Stream body; set Content-Length if known from upstream.
cl = resp.getheader("Content-Length")
data = resp.read()
self.send_header("Content-Length", cl if cl is not None else str(len(data)))
self.end_headers()
if self.command != "HEAD" and data:
self.wfile.write(data)
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
def run_server() -> None:
configure_logging()
server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), GatewayHandler)
server.daemon_threads = True
log.info(
"listening on %s:%s%s (purge_on_delete_request=%s)",
LISTEN_HOST,
LISTEN_PORT,
UPSTREAM,
PURGE_ON_DELETE_REQUEST,
)
try:
server.serve_forever()
except KeyboardInterrupt:
log.info("shutting down")
finally:
server.server_close()
# ---------------------------------------------------------------------------
# Built-in self-test (no Docker): mock Seerr + gateway cascade assertions
# ---------------------------------------------------------------------------
class _MockSeerr(BaseHTTPRequestHandler):
"""Minimal Seerr stand-in for unit tests."""
protocol_version = "HTTP/1.1"
# Shared call log: list of "METHOD path"
calls: List[str] = []
request_payload: dict = {}
file_delete_status: int = 204
request_delete_status: int = 204
require_api_key: Optional[str] = "test-key"
last_request_headers: Dict[str, str] = {}
def log_message(self, fmt: str, *args) -> None: # quiet
return
def _auth_ok(self) -> bool:
if not self.require_api_key:
return True
key = self.headers.get("X-Api-Key") or ""
return key == self.require_api_key
def _send(self, status: int, body: bytes = b"", content_type: str = "application/json") -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
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]
_MockSeerr.last_request_headers = {k: v for k, v in self.headers.items()}
_MockSeerr.calls.append(f"GET {path}")
if path == "/redir":
self.send_response(302)
self.send_header("Location", "http://seerr:5055/login")
self.send_header("Content-Length", "0")
self.end_headers()
return
if path == "/api/v1/settings/public":
self._send(200, b'{"initialized":true}')
return
if not self._auth_ok():
self._send(401, b'{"error":"Unauthorized"}')
return
m = re.match(r"^/api/v1/request/(\d+)/?$", path)
if m:
self._send(200, json.dumps(_MockSeerr.request_payload).encode())
return
self._send(404, b'{"error":"not found"}')
def do_DELETE(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
_MockSeerr.calls.append(f"DELETE {self.path}")
if not self._auth_ok():
self._send(401, b'{"error":"Unauthorized"}')
return
if re.match(r"^/api/v1/media/\d+/file", path):
self._send(_MockSeerr.file_delete_status, b"")
return
if re.match(r"^/api/v1/request/\d+", path):
self._send(_MockSeerr.request_delete_status, b"")
return
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 = {}
queue: List[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
if path == "/api/v3/queue":
self._send(200, json.dumps({"records": list(_MockSonarr.queue)}).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
m = re.match(r"^/api/v3/queue/(\d+)$", path)
if m:
qid = int(m.group(1))
_MockSonarr.queue = [q for q in _MockSonarr.queue if q.get("id") != qid]
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},
],
}
_MockSonarr.queue = [
{
"id": 201,
"seriesId": 10,
"downloadId": "aaa",
"episode": {"seasonNumber": 1},
},
{
"id": 202,
"seriesId": 10,
"downloadId": "bbb",
"episode": {"seasonNumber": 2},
},
{
"id": 203,
"seriesId": 11,
"downloadId": "ccc",
"episode": {"seasonNumber": 1},
},
]
class _MockRadarr(BaseHTTPRequestHandler):
"""Radarr stand-in: movie 1 with queue item 99 and movie 2 with queue item 100."""
protocol_version = "HTTP/1.1"
calls: List[str] = []
movies: List[dict] = []
queue: List[dict] = []
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]
_MockRadarr.calls.append(f"GET {self.path}")
if path == "/api/v3/movie":
self._send(200, json.dumps(list(_MockRadarr.movies)).encode())
return
if path == "/api/v3/queue":
self._send(200, json.dumps({"records": list(_MockRadarr.queue)}).encode())
return
self._send(404, b"{}")
def do_DELETE(self) -> None: # noqa: N802
path = self.path.split("?", 1)[0]
_MockRadarr.calls.append(f"DELETE {path}")
m = re.match(r"^/api/v3/queue/(\d+)$", path)
if m:
qid = int(m.group(1))
_MockRadarr.queue = [q for q in _MockRadarr.queue if q.get("id") != qid]
self._send(200, b"")
return
self._send(404, b"{}")
def _reset_mock_radarr() -> None:
_MockRadarr.calls = []
_MockRadarr.movies = [
{"id": 1, "tmdbId": 10378, "title": "Big Buck Bunny"},
{"id": 2, "tmdbId": 1726, "title": "Iron Man"},
]
_MockRadarr.queue = [
{"id": 99, "movieId": 1, "downloadId": "abc123"},
{"id": 100, "movieId": 2, "downloadId": "iron456"},
]
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def self_test() -> int:
"""Run cascade + passthrough checks against a mock upstream. Exit 0/1."""
configure_logging()
failures = 0
def check(cond: bool, msg: str) -> None:
nonlocal failures
if cond:
print(f" PASS: {msg}")
else:
print(f" FAIL: {msg}")
failures += 1
mock_port = _free_port()
sonarr_port = _free_port()
radarr_port = _free_port()
gw_port = _free_port()
_MockSeerr.calls = []
_MockSeerr.request_payload = {
"id": 42,
"is4k": False,
"media": {
"id": 7,
"tmdbId": 10378,
"mediaType": "movie",
"externalServiceId": 1,
},
}
_MockSeerr.file_delete_status = 204
_MockSeerr.request_delete_status = 204
_MockSeerr.require_api_key = "test-key"
mock_httpd = ThreadingHTTPServer(("127.0.0.1", mock_port), _MockSeerr)
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()
_reset_mock_radarr()
radarr_httpd = ThreadingHTTPServer(("127.0.0.1", radarr_port), _MockRadarr)
radarr_httpd.daemon_threads = True
threading.Thread(target=radarr_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 SONARR_URL, SONARR_API_KEY, SONARR_CONFIG
global RADARR_URL, RADARR_API_KEY, RADARR_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"
RADARR_URL = f"http://127.0.0.1:{radarr_port}"
RADARR_API_KEY = "radarr-test-key"
gw = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), GatewayHandler)
gw.daemon_threads = True
threading.Thread(target=gw.serve_forever, daemon=True).start()
time.sleep(0.15)
def http_call(method: str, url: str, headers: Optional[dict] = None) -> Tuple[int, dict, bytes]:
req = urllib.request.Request(url, method=method, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, dict(resp.headers), resp.read()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
print("=== seerr-gateway self-test ===")
# Health
st, _, body = http_call("GET", f"http://127.0.0.1:{gw_port}/_assemblrr/health")
check(st == 200, "health returns 200")
check(b"purge_on_delete_request" in body, "health reports purge flag")
st, _, _ = http_call("GET", f"http://127.0.0.1:{gw_port}/_assemblrr/ready")
check(st == 200, "ready when upstream up")
# Cascade
_MockSeerr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/42",
{"X-Api-Key": "test-key"},
)
check(st == 204, f"cascade DELETE request → 204 (got {st})")
check(hdrs.get("X-Assemblrr-Request-Delete-Purge") == "1", "purge header set")
calls = list(_MockSeerr.calls)
check(any(c.startswith("GET /api/v1/request/42") for c in calls), "GET request first")
check(
any("DELETE /api/v1/media/7/file" in c for c in calls),
"DELETE media file before request delete",
)
check(any(c.startswith("DELETE /api/v1/request/42") for c in calls), "DELETE request last")
radarr_calls = list(_MockRadarr.calls)
check(
any(c.startswith("GET /api/v3/queue") for c in radarr_calls),
"movie queue listed before title delete",
)
check(
any(c.startswith("DELETE /api/v3/queue/99") for c in radarr_calls),
"movie queue item removed from download client",
)
check(
not any(c.startswith("DELETE /api/v3/queue/100") for c in radarr_calls),
"other movie queue item left alone",
)
check(
"queue_movie:1:1" in (hdrs.get("X-Assemblrr-Purge-Detail") or ""),
f"purge detail notes queue drain ({hdrs.get('X-Assemblrr-Purge-Detail')})",
)
try:
i_get = next(i for i, c in enumerate(calls) if c.startswith("GET /api/v1/request/42"))
i_file = next(i for i, c in enumerate(calls) if "DELETE /api/v1/media/7/file" in c)
i_del = next(i for i, c in enumerate(calls) if c.startswith("DELETE /api/v1/request/42"))
check(i_get < i_file < i_del, f"call order GET < file < request ({calls})")
except StopIteration:
check(False, f"missing expected calls: {calls}")
# Auth failure: do not delete
_MockSeerr.calls = []
st, _, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/42",
{"X-Api-Key": "wrong"},
)
check(st == 401, f"bad API key → 401 (got {st})")
check(
not any(c.startswith("DELETE") for c in _MockSeerr.calls),
"no deletes on auth failure",
)
# Passthrough GET public
_MockSeerr.calls = []
st, _, _ = http_call("GET", f"http://127.0.0.1:{gw_port}/api/v1/settings/public")
check(st == 200, "passthrough public settings")
# Browser Host is forwarded; Location pointing at seerr:5055 is rewritten.
_MockSeerr.last_request_headers = {}
conn = HTTPConnection("127.0.0.1", gw_port, timeout=5)
conn.request("GET", "/api/v1/settings/public", headers={"Host": "localhost:5055"})
resp = conn.getresponse()
resp.read()
conn.close()
got_host = _MockSeerr.last_request_headers.get("Host") or _MockSeerr.last_request_headers.get("host")
got_xfh = (
_MockSeerr.last_request_headers.get("X-Forwarded-Host")
or _MockSeerr.last_request_headers.get("x-forwarded-host")
)
check(got_host == "localhost:5055", f"upstream Host is the browser host (got {got_host})")
check(got_xfh == "localhost:5055", f"X-Forwarded-Host is the browser host (got {got_xfh})")
conn = HTTPConnection("127.0.0.1", gw_port, timeout=5)
conn.request("GET", "/redir", headers={"Host": "localhost:5055"})
resp = conn.getresponse()
loc = resp.getheader("Location") or ""
resp.read()
conn.close()
check(resp.status == 302, f"redir passthrough → 302 (got {resp.status})")
check(loc == "http://localhost:5055/login", f"Location rewritten off seerr:5055 (got {loc})")
# Purge disabled → only request DELETE
PURGE_ON_DELETE_REQUEST = False
_MockSeerr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/99",
{"X-Api-Key": "test-key"},
)
check(st == 204, "purge-disabled still deletes request")
check(
not any("media" in c for c in _MockSeerr.calls),
"purge-disabled skips media file delete",
)
check(
any(c.startswith("DELETE /api/v1/request/99") for c in _MockSeerr.calls),
"purge-disabled forwards request delete",
)
PURGE_ON_DELETE_REQUEST = True
# No media id → still delete request
_MockSeerr.request_payload = {"id": 5, "is4k": False, "media": {}}
_MockSeerr.calls = []
st, hdrs, _ = http_call(
"DELETE",
f"http://127.0.0.1:{gw_port}/api/v1/request/5",
{"X-Api-Key": "test-key"},
)
check(st == 204, "no media id still deletes request")
check(
not any("media" in c and "file" in c for c in _MockSeerr.calls),
"no media file delete when unlinked",
)
check(
"no_media" in (hdrs.get("X-Assemblrr-Purge-Detail") or ""),
"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(
any(c == "DELETE /api/v3/queue/201" for c in _MockSonarr.calls),
"TV S01 removes S01 queue item from download client",
)
check(
not any(c == "DELETE /api/v3/queue/202" for c in _MockSonarr.calls),
"TV S01 leaves S02 queue item",
)
check(
not any(c == "DELETE /api/v3/queue/203" for c in _MockSonarr.calls),
"TV S01 leaves other series queue item",
)
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",
)
# S01 + specials on disk: deleting the S01 request must not title-wipe specials.
_reset_mock_sonarr()
_MockSonarr.files = [
{"id": 1, "seasonNumber": 1, "seriesId": 10},
{"id": 3, "seasonNumber": 0, "seriesId": 10},
]
_MockSonarr.series = {
"id": 10,
"title": "Loki",
"tvdbId": 362472,
"seasons": [
{"seasonNumber": 0, "monitored": True},
{"seasonNumber": 1, "monitored": True},
],
}
_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 = []
_MockSonarr.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 with specials → 204 (got {st})")
check(
not any("DELETE /api/v1/media/6/file" in c for c in _MockSeerr.calls),
"TV S01 with specials does not title-delete",
)
check(
any(c == "DELETE /api/v3/episodefile/1" for c in _MockSonarr.calls),
"TV S01 with specials deletes S01 file",
)
check(
not any(c == "DELETE /api/v3/episodefile/3" for c in _MockSonarr.calls),
"TV S01 with specials keeps specials file",
)
check(
any(f.get("seasonNumber") == 0 for f in _MockSonarr.files),
"specials file still in mock Sonarr",
)
check(
"seasons_deleted" in (hdrs.get("X-Assemblrr-Purge-Detail") or ""),
f"S01+specials purge is season-scoped ({hdrs.get('X-Assemblrr-Purge-Detail')})",
)
# Only specials remain: last regular-season request still must not title-delete.
_reset_mock_sonarr()
_MockSonarr.files = [{"id": 3, "seasonNumber": 0, "seriesId": 10}]
_MockSonarr.series = {
"id": 10,
"title": "Loki",
"tvdbId": 362472,
"seasons": [
{"seasonNumber": 0, "monitored": True},
{"seasonNumber": 1, "monitored": False},
],
}
_MockSeerr.request_payload = {
"id": 8,
"type": "tv",
"is4k": False,
"seasons": [{"seasonNumber": 1}],
"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/8",
{"X-Api-Key": "test-key"},
)
check(st == 204, f"S01 request with only specials left → 204 (got {st})")
check(
not any("DELETE /api/v1/media/6/file" in c for c in _MockSeerr.calls),
"specials-only leftover does not title-delete",
)
check(
any(f.get("seasonNumber") == 0 for f in _MockSonarr.files),
"specials survive last regular-season request delete",
)
# 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",
)
check(
any(c == "DELETE /api/v3/queue/201" for c in _MockSonarr.calls)
and any(c == "DELETE /api/v3/queue/202" for c in _MockSonarr.calls),
"TV all-seasons drains every series queue item",
)
check(
not any(c == "DELETE /api/v3/queue/203" for c in _MockSonarr.calls),
"TV all-seasons leaves other series queue item",
)
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()
radarr_httpd.shutdown()
print("")
if failures:
print(f"self-test: FAILED ({failures})")
return 1
print("self-test: OK")
return 0
def main() -> int:
if len(sys.argv) > 1 and sys.argv[1] in ("--self-test", "self-test"):
return self_test()
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
print(__doc__)
print("Usage: seerr-gateway.py [--self-test]")
print("Env: SEERR_UPSTREAM SEERR_DELETE_REQUEST_PURGE SEERR_GATEWAY_PORT ...")
return 0
run_server()
return 0
if __name__ == "__main__":
sys.exit(main())