"""Asynchronous Python client for Overseerr.""" from __future__ import annotations import asyncio from dataclasses import dataclass from importlib import metadata import socket from typing import TYPE_CHECKING, Any, Literal from urllib.parse import quote from aiohttp import ClientError, ClientResponseError, ClientSession from aiohttp.hdrs import METH_DELETE, METH_GET, METH_POST, METH_PUT from yarl import URL from .exceptions import OverseerrAuthenticationError, OverseerrConnectionError from .models import ( Issue, IssueCount, IssueFilterStatus, IssueResponse, IssueSortStatus, IssueStatus, IssueType, MediaType, MovieDetails, NotificationType, RequestCount, RequestFilterStatus, RequestResponse, RequestSortStatus, RequestWithMedia, Result, SearchResult, Status, TVDetails, WatchlistEntry, WatchlistResponse, WebhookNotificationConfig, ) if TYPE_CHECKING: from typing_extensions import Self VERSION = metadata.version(__package__) @dataclass class OverseerrClient: """Main class for handling connections with Overseerr.""" host: str port: int api_key: str ssl: bool = True session: ClientSession | None = None request_timeout: int = 10 _close_session: bool = False async def _request( self, method: str, uri: str, *, params: dict[str, Any] | None = None, data: dict[str, Any] | None = None, ) -> str: """Handle a request to Overseerr.""" url = URL.build( host=self.host, port=self.port, scheme="https" if self.ssl else "http" ).joinpath(f"api/v1/{uri}") headers = { "User-Agent": f"PythonOverseerr/{VERSION}", "Accept": "application/json", "X-Api-Key": self.api_key, } if self.session is None: self.session = ClientSession() self._close_session = True try: async with asyncio.timeout(self.request_timeout): response = await self.session.request( method, url, params=params, headers=headers, json=data, ) except asyncio.TimeoutError as exception: msg = "Timeout occurred while connecting to the service" raise OverseerrConnectionError(msg) from exception except ( ClientError, ClientResponseError, socket.gaierror, ) as exception: msg = "Error occurred while communicating with the service" raise OverseerrConnectionError(msg) from exception if response.status == 403: msg = "Invalid API key" raise OverseerrAuthenticationError(msg) if response.status >= 400: content_type = response.headers.get("Content-Type", "") text = await response.text() msg = "Unexpected response from Overseerr" raise OverseerrConnectionError( msg, {"Content-Type": content_type, "response": text}, ) return await response.text() async def get_request_count(self) -> RequestCount: """Get request count from Overseerr.""" response = await self._request(METH_GET, "request/count") return RequestCount.from_json(response) async def get_issue_count(self) -> IssueCount: """Get issue count from Overseerr.""" response = await self._request(METH_GET, "issue/count") return IssueCount.from_json(response) async def get_status(self) -> Status: """Get status from Overseerr.""" response = await self._request(METH_GET, "status") return Status.from_json(response) async def search(self, keyword: str) -> list[Result]: """Search for media in Overseerr.""" response = await self._request( METH_GET, "search", params={"query": quote(keyword)} ) return SearchResult.from_json(response).results async def get_webhook_notification_config(self) -> WebhookNotificationConfig: """Get webhook notification config from Overseerr.""" response = await self._request(METH_GET, "settings/notifications/webhook") return WebhookNotificationConfig.from_json(response) async def get_requests( self, status: RequestFilterStatus | None = None, sort: RequestSortStatus | None = None, requested_by: int | None = None, ) -> list[RequestWithMedia]: """Get requests from Overseerr.""" params: dict[str, Any] = {} if status: params["filter"] = status if sort: params["sort"] = sort if requested_by: params["requestedBy"] = requested_by response = await self._request(METH_GET, "request", params=params) return RequestResponse.from_json(response).results async def create_request( self, media_type: MediaType, tmdb_id: int, seasons: list[int] | Literal["all"] | None = None, ) -> RequestWithMedia: """Create a request in Overseerr.""" data = {"mediaType": media_type, "mediaId": tmdb_id} if seasons: data["seasons"] = seasons response = await self._request(METH_POST, "request", data=data) return RequestWithMedia.from_json(response) async def get_issues( self, status: IssueFilterStatus | None = None, sort: IssueSortStatus | None = None, requested_by: int | None = None, ) -> list[Issue]: """Get issues from Overseerr.""" params: dict[str, Any] = {} if status: params["filter"] = status if sort: params["sort"] = sort if requested_by: params["requestedBy"] = requested_by response = await self._request(METH_GET, "issue", params=params) return IssueResponse.from_json(response).results async def get_issue(self, issue_id: int) -> Issue: """Get a single issue from Overseerr.""" response = await self._request(METH_GET, f"issue/{issue_id}") return Issue.from_json(response) async def create_issue( self, issue_type: IssueType, message: str, media_id: int, problem_season: int = 0, problem_episode: int = 0, ) -> Issue: """Create a new issue in Overseerr.""" data = { "issueType": issue_type.value, "message": message, "mediaId": media_id, "problemSeason": problem_season, "problemEpisode": problem_episode, } response = await self._request(METH_POST, "issue", data=data) return Issue.from_json(response) async def update_issue( self, issue_id: int, *, status: IssueStatus | None = None, message: str | None = None, ) -> Issue: """Update an existing issue in Overseerr.""" data: dict[str, Any] = {} if status is not None: data["status"] = status.value if message is not None: data["message"] = message response = await self._request(METH_PUT, f"issue/{issue_id}", data=data) return Issue.from_json(response) async def delete_issue(self, issue_id: int) -> None: """Delete an issue from Overseerr.""" await self._request(METH_DELETE, f"issue/{issue_id}") async def get_movie_details(self, identifier: int) -> MovieDetails: """Get movie details from Overseerr.""" response = await self._request(METH_GET, f"movie/{identifier}") return MovieDetails.from_json(response) async def get_tv_details(self, identifier: int) -> TVDetails: """Get tv details from Overseerr.""" response = await self._request(METH_GET, f"tv/{identifier}") return TVDetails.from_json(response) async def get_watchlist(self) -> list[WatchlistEntry]: """Get watchlist from Overseerr.""" response = await self._request(METH_GET, "discover/watchlist") return WatchlistResponse.from_json(response).results async def test_webhook_notification_config( self, webhook_url: str, json_payload: str ) -> bool: """Test webhook notification config with Overseerr.""" try: await self._request( METH_POST, "settings/notifications/webhook/test", data={ "enabled": True, "types": NotificationType.REQUEST_PENDING_APPROVAL, "options": {"webhookUrl": webhook_url, "jsonPayload": json_payload}, }, ) except OverseerrConnectionError: return False return True async def set_webhook_notification_config( self, *, enabled: bool, types: NotificationType, webhook_url: str, json_payload: str, ) -> None: """Get webhook notification config from Overseerr.""" await self._request( METH_POST, "settings/notifications/webhook", data={ "enabled": enabled, "types": types, "options": {"webhookUrl": webhook_url, "jsonPayload": json_payload}, }, ) async def close(self) -> None: """Close open client session.""" if self.session and self._close_session: await self.session.close() async def __aenter__(self) -> Self: """Async enter. Returns ------- The OverseerrClient object. """ return self async def __aexit__(self, *_exc_info: object) -> None: """Async exit. Args: ---- _exc_info: Exec type. """ await self.close()