mirror of
https://github.com/soulis-1256/assemblrr.git
synced 2026-08-29 09:31:40 +00:00
1736 lines
60 KiB
Python
Executable file
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())
|