from typing import Any, Dict, Optional from urllib.parse import quote, unquote, urlsplit import httpx from .base import ApiClient class JellyseerrClient(ApiClient): async def _send_request( self, client: httpx.AsyncClient, method: str, url: str, *, headers: Dict[str, str], params: Optional[Dict[str, Any]], payload: Optional[Dict[str, Any]], ) -> httpx.Response: request_headers = dict(headers) if method.upper() in {"POST", "PUT", "PATCH", "DELETE"} and self.base_url: # Seerr's optional CSRF protection also applies to API-key writes. # Seed its secret/token cookie pair, then echo the readable token in # the header Seerr's own web client uses. csrf_response = await client.get( f"{self.base_url}/api/v1/auth/me", headers=self.headers(), ) csrf_response.raise_for_status() csrf_token = client.cookies.get("XSRF-TOKEN") if csrf_token: request_headers["XSRF-TOKEN"] = unquote(csrf_token) parsed_base = urlsplit(self.base_url) request_headers["Origin"] = f"{parsed_base.scheme}://{parsed_base.netloc}" return await super()._send_request( client, method, url, headers=request_headers, params=params, payload=payload, ) async def get_status(self) -> Optional[Dict[str, Any]]: return await self.get("/api/v1/status") async def get_request(self, request_id: str) -> Optional[Dict[str, Any]]: return await self.get(f"/api/v1/request/{request_id}") async def get_recent_requests(self, take: int = 10, skip: int = 0) -> Optional[Dict[str, Any]]: return await self.get( "/api/v1/request", params={ "take": take, "skip": skip, }, ) async def get_movie(self, tmdb_id: int) -> Optional[Dict[str, Any]]: return await self.get(f"/api/v1/movie/{tmdb_id}") async def get_tv(self, tmdb_id: int) -> Optional[Dict[str, Any]]: return await self.get(f"/api/v1/tv/{tmdb_id}") async def search(self, query: str, page: int = 1) -> Optional[Dict[str, Any]]: # Seerr rejects the `+` encoding that standard query builders use for # spaces. Build this query explicitly so multi-word titles are sent as # percent-encoded values. encoded_query = quote(query, safe="") return await self.get(f"/api/v1/search?query={encoded_query}&page={page}") async def get_service_settings(self, media_type: str) -> Optional[Any]: service = "sonarr" if media_type == "tv" else "radarr" return await self.get(f"/api/v1/settings/{service}") async def create_request( self, *, media_type: str, media_id: int, seasons: Optional[list[int]] = None, is_4k: Optional[bool] = None, server_id: Optional[int] = None, profile_id: Optional[int] = None, root_folder: Optional[str] = None, ) -> Optional[Dict[str, Any]]: payload: Dict[str, Any] = { "mediaType": media_type, "mediaId": media_id, } if isinstance(seasons, list) and seasons: payload["seasons"] = seasons if isinstance(is_4k, bool): payload["is4k"] = is_4k if isinstance(server_id, int): payload["serverId"] = server_id if isinstance(profile_id, int): payload["profileId"] = profile_id if isinstance(root_folder, str) and root_folder.strip(): payload["rootFolder"] = root_folder.strip() return await self.post("/api/v1/request", payload=payload) async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]: return await self.get( "/api/v1/user", params={ "take": take, "skip": skip, }, ) async def get_user(self, user_id: int) -> Optional[Dict[str, Any]]: return await self.get(f"/api/v1/user/{user_id}") async def delete_user(self, user_id: int) -> Optional[Dict[str, Any]]: return await self.delete(f"/api/v1/user/{user_id}") async def login_local(self, email: str, password: str) -> Optional[Dict[str, Any]]: payload = {"email": email, "password": password} try: return await self.post("/api/v1/auth/local", payload=payload) except httpx.HTTPStatusError as exc: # Backward compatibility for older Seerr/Overseerr deployments # that still expose /auth/login instead of /auth/local. if exc.response is not None and exc.response.status_code in {404, 405}: return await self.post("/api/v1/auth/login", payload=payload) raise