From 9fe79429b18a1f0e93d76fbeed7eee92e904284f Mon Sep 17 00:00:00 2001 From: jharder01 Date: Tue, 20 May 2025 22:48:53 -0700 Subject: [PATCH 01/30] feat: expand SSL verification handling to API requests in Radarr, Readarr, Sonarr, and Swaparr --- src/primary/apps/radarr/api.py | 11 ++++++++--- src/primary/apps/readarr/api.py | 6 +++--- src/primary/apps/sonarr/api.py | 26 +++++++++++++------------- src/primary/apps/swaparr/handler.py | 13 +++++++++---- 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/src/primary/apps/radarr/api.py b/src/primary/apps/radarr/api.py index 87153ea1..d7563a7c 100644 --- a/src/primary/apps/radarr/api.py +++ b/src/primary/apps/radarr/api.py @@ -102,7 +102,10 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int # Radarr uses /api/v3/queue endpoint = f"{api_url.rstrip('/')}/api/v3/queue?page=1&pageSize=1000" # Fetch a large page size headers = {"X-Api-Key": api_key} - response = session.get(endpoint, headers=headers, timeout=api_timeout) + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: + radarr_logger.debug("SSL verification disabled by user setting for queue size check") + response = session.get(endpoint, headers=headers, timeout=api_timeout, verify=verify_ssl) response.raise_for_status() queue_data = response.json() queue_size = queue_data.get('totalRecords', 0) @@ -277,8 +280,10 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: # Ensure URL doesn't end with a slash before adding the endpoint base_url = api_url.rstrip('/') full_url = f"{base_url}/api/v3/system/status" - - response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout) + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: + radarr_logger.debug("SSL verification disabled by user setting for connection check") + response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=verify_ssl) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) radarr_logger.debug("Successfully connected to Radarr.") return True diff --git a/src/primary/apps/readarr/api.py b/src/primary/apps/readarr/api.py index b34e9ad9..e06e6738 100644 --- a/src/primary/apps/readarr/api.py +++ b/src/primary/apps/readarr/api.py @@ -48,7 +48,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: } logger.debug(f"Using User-Agent: {headers['User-Agent']}") - response = requests.get(full_url, headers=headers, timeout=api_timeout) + response = requests.get(full_url, headers=headers, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) logger.debug("Successfully connected to Readarr.") return True @@ -310,7 +310,7 @@ def get_wanted_missing_books(api_url: str, api_key: str, api_timeout: int, monit # 'monitored': monitored_only # Note: Check if Readarr API supports this directly for wanted/missing } try: - response = requests.get(url, headers=headers, params=params, timeout=api_timeout) + response = requests.get(url, headers=headers, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() data = response.json() @@ -409,7 +409,7 @@ def search_books(api_url: str, api_key: str, book_ids: List[int], api_timeout: i } try: # This uses requests.post directly, not arr_request. It's already correct. - response = requests.post(endpoint, headers=headers, json=payload, timeout=api_timeout) + response = requests.post(endpoint, headers=headers, json=payload, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() command_data = response.json() command_id = command_data.get('id') diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 7b82b559..ff3b074d 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -295,7 +295,7 @@ def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored sonarr_logger.debug(f"Requesting missing episodes page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) + response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() # Check for HTTP errors (4xx or 5xx) if not response.content: @@ -411,7 +411,7 @@ def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, moni sonarr_logger.debug(f"Requesting cutoff unmet page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) + response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}") response.raise_for_status() # Check for HTTP errors @@ -551,7 +551,7 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou try: # Get total record count from a minimal query - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) + response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() data = response.json() total_records = data.get('totalRecords', 0) @@ -579,7 +579,7 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou "includeSeries": "true" } - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) + response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() data = response.json() @@ -648,7 +648,7 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in try: # Get total record count from a minimal query sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) + response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() if not response.content: @@ -688,7 +688,7 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in if series_id is not None: params["seriesId"] = series_id - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) + response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() if not response.content: @@ -762,7 +762,7 @@ def search_episode(api_url: str, api_key: str, api_timeout: int, episode_ids: Li "name": "EpisodeSearch", "episodeIds": episode_ids } - response = requests.post(endpoint, headers={"X-Api-Key": api_key}, json=payload, timeout=api_timeout) + response = requests.post(endpoint, headers={"X-Api-Key": api_key}, json=payload, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() command_id = response.json().get('id') sonarr_logger.info(f"Triggered Sonarr search for episode IDs: {episode_ids}. Command ID: {command_id}") @@ -778,7 +778,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: """Get the status of a Sonarr command.""" try: endpoint = f"{api_url}/api/v3/command/{command_id}" - response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout) + response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() status = response.json() sonarr_logger.debug(f"Checked Sonarr command status for ID {command_id}: {status.get('status')}") @@ -798,7 +798,7 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int for attempt in range(retries + 1): try: endpoint = f"{api_url}/api/v3/queue?page=1&pageSize=1" # Just get total count, don't need records - response = requests.get(endpoint, headers={"X-Api-Key": api_key}, params={"includeSeries": "false"}, timeout=api_timeout) + response = requests.get(endpoint, headers={"X-Api-Key": api_key}, params={"includeSeries": "false"}, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() if not response.content: @@ -850,7 +850,7 @@ def get_series_by_id(api_url: str, api_key: str, api_timeout: int, series_id: in """Get series details by ID from Sonarr.""" try: endpoint = f"{api_url}/api/v3/series/{series_id}" - response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout) + response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() series_data = response.json() sonarr_logger.debug(f"Fetched details for Sonarr series ID: {series_id}") @@ -871,7 +871,7 @@ def search_season(api_url: str, api_key: str, api_timeout: int, series_id: int, "seriesId": series_id, "seasonNumber": season_number } - response = requests.post(endpoint, headers={"X-Api-Key": api_key}, json=payload, timeout=api_timeout) + response = requests.post(endpoint, headers={"X-Api-Key": api_key}, json=payload, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() command_id = response.json().get('id') sonarr_logger.info(f"Triggered Sonarr season search for series ID: {series_id}, season: {season_number}. Command ID: {command_id}") @@ -926,7 +926,7 @@ def get_cutoff_unmet_episodes_for_series(api_url: str, api_key: str, api_timeout sonarr_logger.debug(f"Requesting cutoff unmet page {page} for series {series_id} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) + response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}") response.raise_for_status() # Check for HTTP errors @@ -1074,7 +1074,7 @@ def get_series_with_missing_episodes(api_url: str, api_key: str, api_timeout: in # Get all episodes for this series try: endpoint = f"{api_url}/api/v3/episode?seriesId={series_id}" - response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout) + response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() if not response.content: diff --git a/src/primary/apps/swaparr/handler.py b/src/primary/apps/swaparr/handler.py index 7431f381..13eb4059 100644 --- a/src/primary/apps/swaparr/handler.py +++ b/src/primary/apps/swaparr/handler.py @@ -13,6 +13,7 @@ from src.primary.utils.logger import get_logger from src.primary.settings_manager import load_settings from src.primary.state import get_state_file_path +from src.primary.settings_manager import get_ssl_verify_setting # Create logger swaparr_logger = get_logger("swaparr") @@ -165,9 +166,11 @@ def get_queue_items(app_name, api_url, api_key, api_timeout=120): # Add pagination parameters queue_url = f"{api_url.rstrip('/')}/api/{api_version}/queue?page={page}&pageSize={page_size}" headers = {'X-Api-Key': api_key} - + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: + swaparr_logger.debug("SSL verification disabled by user setting for get_queue_items") try: - response = requests.get(queue_url, headers=headers, timeout=api_timeout) + response = requests.get(queue_url, headers=headers, timeout=api_timeout, verify=verify_ssl) response.raise_for_status() queue_data = response.json() @@ -268,9 +271,11 @@ def delete_download(app_name, api_url, api_key, download_id, remove_from_client= api_version = api_version_map.get(app_name, "v3") delete_url = f"{api_url.rstrip('/')}/api/{api_version}/queue/{download_id}?removeFromClient={str(remove_from_client).lower()}&blocklist=true" headers = {'X-Api-Key': api_key} - + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: + swaparr_logger.debug("SSL verification disabled by user setting for delete_download") try: - response = requests.delete(delete_url, headers=headers, timeout=api_timeout) + response = requests.delete(delete_url, headers=headers, timeout=api_timeout, verify=verify_ssl) response.raise_for_status() swaparr_logger.info(f"Successfully removed download {download_id} from {app_name}") return True From 04dace7afd2acd09abdab3adbb48e4304ff04841 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Wed, 21 May 2025 08:26:21 -0700 Subject: [PATCH 02/30] feat: add optional SSL verification parameter to API request functions across multiple apps --- src/primary/apps/eros/api.py | 43 +++++++++++++++++--------------- src/primary/apps/lidarr/api.py | 16 ++++++------ src/primary/apps/readarr/api.py | 14 ++++++----- src/primary/apps/sonarr/api.py | 15 ++++++----- src/primary/apps/whisparr/api.py | 19 +++++++------- 5 files changed, 58 insertions(+), 49 deletions(-) diff --git a/src/primary/apps/eros/api.py b/src/primary/apps/eros/api.py index 386d0a97..a7af1e19 100644 --- a/src/primary/apps/eros/api.py +++ b/src/primary/apps/eros/api.py @@ -22,7 +22,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: """ Make a request to the Eros API. @@ -33,6 +33,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests + verify_ssl: Whether to verify SSL certificates (overrides global setting if provided) Returns: The parsed JSON response or None if the request failed @@ -60,7 +61,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met } # Get SSL verification setting - verify_ssl = get_ssl_verify_setting() + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() if not verify_ssl: eros_logger.debug("SSL verification disabled by user setting") @@ -106,7 +108,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met eros_logger.error(f"Unexpected error during API request: {e}") return None -def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int: +def get_download_queue_size(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> int: """ Get the current size of the download queue. @@ -118,7 +120,7 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int Returns: The number of items in the download queue, or -1 if the request failed """ - response = arr_request(api_url, api_key, api_timeout, "queue") + response = arr_request(api_url, api_key, api_timeout, "queue", verify_ssl=verify_ssl) if response is None: return -1 @@ -132,7 +134,7 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int else: return -1 -def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie") -> List[Dict[str, Any]]: +def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie", verify_ssl: Optional[bool] = None) -> List[Dict[str, Any]]: """ Get a list of items with missing files (not downloaded/available). @@ -153,7 +155,7 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor # In movie mode, we get all movies and filter for ones without files endpoint = "movie" - response = arr_request(api_url, api_key, api_timeout, endpoint) + response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) if response is None: return None @@ -172,12 +174,12 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor # First check if the movie-scene endpoint exists endpoint = "scene/missing?pageSize=1000" - response = arr_request(api_url, api_key, api_timeout, endpoint) + response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) if response is None: # Fallback to regular movie filtering if scene endpoint doesn't exist eros_logger.warning("Scene endpoint not available, falling back to movie mode") - return get_items_with_missing(api_url, api_key, api_timeout, monitored_only, "movie") + return get_items_with_missing(api_url, api_key, api_timeout, monitored_only, "movie", verify_ssl=verify_ssl) # Extract the scenes items = [] @@ -203,7 +205,7 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor eros_logger.error(f"Error retrieving missing items: {str(e)}") return None -def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]: +def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, verify_ssl: Optional[bool] = None) -> List[Dict[str, Any]]: """ Get a list of items that don't meet their quality profile cutoff. @@ -222,7 +224,7 @@ def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitor # Endpoint endpoint = "wanted/cutoff?pageSize=1000&sortKey=airDateUtc&sortDirection=descending" - response = arr_request(api_url, api_key, api_timeout, endpoint) + response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) if response is None: return None @@ -247,7 +249,7 @@ def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitor eros_logger.error(f"Error retrieving cutoff unmet items: {str(e)}") return None -def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie") -> List[Dict[str, Any]]: +def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie", verify_ssl: Optional[bool] = None) -> List[Dict[str, Any]]: """ Get a list of items that can be upgraded to better quality. @@ -268,7 +270,7 @@ def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored # In movie mode, we get all movies and filter for ones that have files but need quality upgrades endpoint = "movie" - response = arr_request(api_url, api_key, api_timeout, endpoint) + response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) if response is None: return None @@ -286,12 +288,12 @@ def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored # In scene mode, try to use scene-specific endpoints endpoint = "scene/cutoff?pageSize=1000" - response = arr_request(api_url, api_key, api_timeout, endpoint) + response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) if response is None: # Fallback to regular movie filtering if scene endpoint doesn't exist eros_logger.warning("Scene cutoff endpoint not available, falling back to movie mode") - return get_quality_upgrades(api_url, api_key, api_timeout, monitored_only, "movie") + return get_quality_upgrades(api_url, api_key, api_timeout, monitored_only, "movie", verify_ssl=verify_ssl) # Extract the scenes items = [] @@ -335,7 +337,7 @@ def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int) -> # Return a placeholder command ID to simulate success without actually refreshing return 123 -def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int]) -> int: +def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int], verify_ssl: Optional[bool] = None) -> int: """ Trigger a search for one or more movies in Whisparr V3. @@ -416,7 +418,7 @@ def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int eros_logger.debug(f"Trying search command format {i+1}: {payload}") # Make the API request - response = arr_request(api_url, api_key, api_timeout, command_endpoint, "POST", payload) + response = arr_request(api_url, api_key, api_timeout, command_endpoint, "POST", payload, verify_ssl=verify_ssl) if response and "id" in response: command_id = response["id"] @@ -431,7 +433,7 @@ def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int eros_logger.error(f"Error searching for movies: {str(e)}") return None -def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int) -> Optional[Dict]: +def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int, verify_ssl: Optional[bool] = None) -> Optional[Dict]: """ Get the status of a specific command. @@ -452,7 +454,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: command_endpoint = f"command/{command_id}" # Make the API request - result = arr_request(api_url, api_key, api_timeout, command_endpoint) + result = arr_request(api_url, api_key, api_timeout, command_endpoint, verify_ssl=verify_ssl) if result: eros_logger.debug(f"Command {command_id} status: {result.get('status', 'unknown')}") @@ -465,7 +467,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: eros_logger.error(f"Error getting command status for ID {command_id}: {e}") return None -def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: """ Check the connection to Whisparr V3 API. @@ -473,6 +475,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: api_url: The base URL of the Whisparr V3 API api_key: The API key for authentication api_timeout: Timeout for the API request + verify_ssl: Whether to verify SSL certificates (overrides global setting if provided) Returns: True if the connection is successful, False otherwise @@ -481,7 +484,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: eros_logger.debug(f"Checking connection to Whisparr V3 instance at {api_url}") endpoint = "system/status" - response = arr_request(api_url, api_key, api_timeout, endpoint) + response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) if response is not None: # Get the version information if available diff --git a/src/primary/apps/lidarr/api.py b/src/primary/apps/lidarr/api.py index ed1ba90a..b178648d 100644 --- a/src/primary/apps/lidarr/api.py +++ b/src/primary/apps/lidarr/api.py @@ -21,7 +21,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None, verify_ssl: Optional[bool] = None) -> Any: """ Make a request to the Lidarr API. @@ -33,6 +33,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met method: HTTP method (GET, POST, PUT, DELETE) data: Optional data to send with the request params: Optional query parameters + verify_ssl: Optional override for SSL verification Returns: The JSON response from the API, or None if the request failed @@ -59,8 +60,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met lidarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") # Get SSL verification setting - verify_ssl = get_ssl_verify_setting() - + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() if not verify_ssl: lidarr_logger.debug("SSL verification disabled by user setting") @@ -153,7 +154,7 @@ def get_system_status(api_url: str, api_key: str, api_timeout: int, verify_ssl: lidarr_logger.error(f"Error getting system status: {str(e)}") return {} -def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: """Checks connection by fetching system status.""" if not api_url: lidarr_logger.error("API URL is empty or not set") @@ -166,8 +167,8 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: # Use a shorter timeout for a quick connection check quick_timeout = min(api_timeout, 15) - # Get SSL verification setting - verify_ssl = get_ssl_verify_setting() + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() status = get_system_status(api_url, api_key, quick_timeout, verify_ssl) if status and isinstance(status, dict) and 'version' in status: @@ -179,8 +180,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: lidarr_logger.warning(f"Connection check for {api_url} returned unexpected status: {str(status)[:200]}") return False except Exception as e: - # Error should have been logged by arr_request, just indicate failure - lidarr_logger.error(f"Connection check failed for {api_url}: {str(e)}") + lidarr_logger.error(f"Connection check failed for {api_url}") return False def get_artists(api_url: str, api_key: str, api_timeout: int, artist_id: Optional[int] = None) -> Union[List, Dict, None]: diff --git a/src/primary/apps/readarr/api.py b/src/primary/apps/readarr/api.py index e06e6738..864a06f5 100644 --- a/src/primary/apps/readarr/api.py +++ b/src/primary/apps/readarr/api.py @@ -24,7 +24,7 @@ # Default API timeout in seconds - used as fallback only API_TIMEOUT = 30 -def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: """Check the connection to Readarr API.""" try: # Ensure api_url is properly formatted @@ -47,8 +47,9 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: "User-Agent": "Huntarr/1.0 (https://github.com/plexguide/Huntarr.io)" } logger.debug(f"Using User-Agent: {headers['User-Agent']}") - - response = requests.get(full_url, headers=headers, timeout=api_timeout, verify=get_ssl_verify_setting()) + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() + response = requests.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) logger.debug("Successfully connected to Readarr.") return True @@ -105,7 +106,7 @@ def get_download_queue_size(api_url: str = None, api_key: str = None, timeout: i def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: str = "readarr", api_url: str = None, api_key: str = None, api_timeout: int = None, - params: Dict = None, instance_data: Dict = None) -> Any: + params: Dict = None, instance_data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: """ Make a request to the Readarr API. @@ -122,6 +123,7 @@ def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: api_timeout: Optional timeout override params: Optional query parameters instance_data: Optional specific instance data to use + verify_ssl: Optional override for SSL verification Returns: The parsed JSON response or None if the request failed @@ -191,8 +193,8 @@ def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: } # Get SSL verification setting - verify_ssl = get_ssl_verify_setting() - + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() if not verify_ssl: logger.debug("SSL verification disabled by user setting") diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index ff3b074d..965c1fd6 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -21,7 +21,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: """ Make a request to the Sonarr API. @@ -32,6 +32,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests + verify_ssl: Optional override for SSL verification Returns: The parsed JSON response or None if the request failed @@ -62,8 +63,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") # Get SSL verification setting - verify_ssl = get_ssl_verify_setting() - + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() if not verify_ssl: sonarr_logger.debug("SSL verification disabled by user setting") @@ -128,7 +129,8 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: try: # Use a shorter timeout for a quick connection check quick_timeout = min(api_timeout, 15) - status = get_system_status(api_url, api_key, quick_timeout) + verify_ssl = get_ssl_verify_setting() + status = get_system_status(api_url, api_key, quick_timeout, verify_ssl=verify_ssl) if status and isinstance(status, dict) and 'version' in status: # Log success only if debug is enabled to avoid clutter sonarr_logger.debug(f"Connection check successful for {api_url}. Version: {status.get('version')}") @@ -142,7 +144,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: sonarr_logger.error(f"Connection check failed for {api_url}") return False -def get_system_status(api_url: str, api_key: str, api_timeout: int) -> Dict: +def get_system_status(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> Dict: """ Get Sonarr system status. @@ -150,11 +152,12 @@ def get_system_status(api_url: str, api_key: str, api_timeout: int) -> Dict: api_url: The base URL of the Sonarr API api_key: The API key for authentication api_timeout: Timeout for the API request + verify_ssl: Optional override for SSL verification Returns: System status information or empty dict if request failed """ - response = arr_request(api_url, api_key, api_timeout, "system/status") + response = arr_request(api_url, api_key, api_timeout, "system/status", verify_ssl=verify_ssl) if response: return response return {} diff --git a/src/primary/apps/whisparr/api.py b/src/primary/apps/whisparr/api.py index 8a6aab7a..e6c02625 100644 --- a/src/primary/apps/whisparr/api.py +++ b/src/primary/apps/whisparr/api.py @@ -22,7 +22,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: """ Make a request to the Whisparr API. @@ -33,6 +33,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests + verify_ssl: Optional override for SSL verification Returns: The parsed JSON response or None if the request failed @@ -60,8 +61,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met } # Get SSL verification setting - verify_ssl = get_ssl_verify_setting() - + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() if not verify_ssl: whisparr_logger.debug("SSL verification disabled by user setting") @@ -365,7 +366,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: whisparr_logger.error(f"Error getting command status for ID {command_id}: {e}") return None -def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: """ Check the connection to Whisparr V2 API. @@ -373,6 +374,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: api_url: The base URL of the Whisparr API api_key: The API key for authentication api_timeout: Timeout for the API request + verify_ssl: Optional override for SSL verification Returns: True if the connection is successful, False otherwise @@ -383,17 +385,16 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: # First try with standard path endpoint = "system/status" - response = arr_request(api_url, api_key, api_timeout, endpoint) - - # If that failed, try with v3 path format + response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) if response is None: whisparr_logger.debug("Standard API path failed, trying v3 format...") # Try direct HTTP request to v3 endpoint without using arr_request url = f"{api_url.rstrip('/')}/api/v3/system/status" headers = {'X-Api-Key': api_key} - + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() try: - resp = session.get(url, headers=headers, timeout=api_timeout) + resp = session.get(url, headers=headers, timeout=api_timeout, verify=verify_ssl) resp.raise_for_status() response = resp.json() except Exception as e: From 4eddbc0e070b31be4d1e8311a615277189c60abe Mon Sep 17 00:00:00 2001 From: jharder01 Date: Wed, 21 May 2025 08:49:18 -0700 Subject: [PATCH 03/30] 'feat: add optional SSL verification parameter to arr_request function in Radarr API' --- src/primary/apps/radarr/api.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/primary/apps/radarr/api.py b/src/primary/apps/radarr/api.py index d7563a7c..6590abdd 100644 --- a/src/primary/apps/radarr/api.py +++ b/src/primary/apps/radarr/api.py @@ -20,7 +20,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: """ Make a request to the Radarr API. @@ -31,6 +31,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call (without /api/v3/) method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests + verify_ssl: Optional SSL verification flag. If not provided, falls back to global setting. Returns: The parsed JSON response or None if the request failed @@ -53,8 +54,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met } # Get SSL verification setting - verify_ssl = get_ssl_verify_setting() - + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() if not verify_ssl: radarr_logger.debug("SSL verification disabled by user setting") @@ -132,7 +133,7 @@ def get_movies_with_missing(api_url: str, api_key: str, api_timeout: int, monito A list of movie objects with missing files, or None if the request failed. """ # Use the updated arr_request with passed arguments - movies = arr_request(api_url, api_key, api_timeout, "movie") + movies = arr_request(api_url, api_key, api_timeout, "movie", verify_ssl=None) if movies is None: # Check for None explicitly, as an empty list is valid radarr_logger.error("Failed to retrieve movies from Radarr API.") return None @@ -166,14 +167,14 @@ def get_cutoff_unmet_movies(api_url: str, api_key: str, api_timeout: int, monito # We need to fetch all movies and filter locally, or use the /api/v3/movie/lookup endpoint if searching by TMDB/IMDB ID. # Fetching all movies is simpler for now. radarr_logger.debug("Fetching all movies to determine cutoff unmet status...") - movies = arr_request(api_url, api_key, api_timeout, "movie") + movies = arr_request(api_url, api_key, api_timeout, "movie", verify_ssl=None) if movies is None: radarr_logger.error("Failed to retrieve movies from Radarr API for cutoff check.") return None # Need quality profile information to determine cutoff unmet status. # Fetch quality profiles first. - profiles = arr_request(api_url, api_key, api_timeout, "qualityprofile") + profiles = arr_request(api_url, api_key, api_timeout, "qualityprofile", verify_ssl=None) if profiles is None: radarr_logger.error("Failed to retrieve quality profiles from Radarr API.") return None @@ -255,7 +256,7 @@ def movie_search(api_url: str, api_key: str, api_timeout: int, movie_ids: List[i } # Use the updated arr_request - response = arr_request(api_url, api_key, api_timeout, endpoint, method="POST", data=data) + response = arr_request(api_url, api_key, api_timeout, endpoint, method="POST", data=data, verify_ssl=None) if response and 'id' in response: command_id = response['id'] radarr_logger.debug(f"Triggered search for movie IDs: {movie_ids}. Command ID: {command_id}") @@ -312,7 +313,7 @@ def wait_for_command(api_url: str, api_key: str, api_timeout: int, command_id: i """ attempts = 0 while attempts < max_attempts: - response = arr_request(api_url, api_key, api_timeout, f"command/{command_id}") + response = arr_request(api_url, api_key, api_timeout, f"command/{command_id}", verify_ssl=None) if response and 'state' in response: state = response['state'] if state == "completed": From 90f3bc4430c0486fb8f57c44bf36ac23a0f72974 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Wed, 21 May 2025 22:19:23 -0700 Subject: [PATCH 04/30] feat: add optional SSL verification parameter to arr_request function in Radarr API --- src/primary/apps/radarr/api.py | 5 +++-- src/primary/background.py | 9 ++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/primary/apps/radarr/api.py b/src/primary/apps/radarr/api.py index 6590abdd..2d78603a 100644 --- a/src/primary/apps/radarr/api.py +++ b/src/primary/apps/radarr/api.py @@ -265,7 +265,7 @@ def movie_search(api_url: str, api_key: str, api_timeout: int, movie_ids: List[i radarr_logger.error(f"Failed to trigger search command for movie IDs {movie_ids}. Response: {response}") return None -def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: """Check the connection to Radarr API.""" try: # Ensure api_url is properly formatted @@ -281,7 +281,8 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: # Ensure URL doesn't end with a slash before adding the endpoint base_url = api_url.rstrip('/') full_url = f"{base_url}/api/v3/system/status" - verify_ssl = get_ssl_verify_setting() + if verify_ssl is None: + verify_ssl = get_ssl_verify_setting() if not verify_ssl: radarr_logger.debug("SSL verification disabled by user setting for connection check") response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=verify_ssl) diff --git a/src/primary/background.py b/src/primary/background.py index 7a338b88..fe6ada33 100644 --- a/src/primary/background.py +++ b/src/primary/background.py @@ -227,7 +227,14 @@ def app_specific_loop(app_type: str) -> None: try: # Use instance details for connection check app_logger.debug(f"Checking connection to {app_type} instance '{instance_name}' at {api_url} with timeout {api_timeout}s") - connected = check_connection(api_url, api_key, api_timeout=api_timeout) + # Determine SSL verify setting: instance-level, app-level, or global + verify_ssl = instance_details.get("ssl_verify") + if verify_ssl is None: + verify_ssl = app_settings.get("ssl_verify") + if verify_ssl is None: + from src.primary.settings_manager import get_ssl_verify_setting + verify_ssl = get_ssl_verify_setting() + connected = check_connection(api_url, api_key, api_timeout=api_timeout, verify_ssl=verify_ssl) if not connected: app_logger.warning(f"Failed to connect to {app_type} instance '{instance_name}' at {api_url}. Skipping.") continue From 4bb4b203c48a63645ef139a6fa61825dc9dd2e44 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Wed, 21 May 2025 22:59:18 -0700 Subject: [PATCH 05/30] feat: refactor test_connection to use arr_request for API status check --- src/primary/apps/radarr.py | 93 +++++--------------------------------- 1 file changed, 11 insertions(+), 82 deletions(-) diff --git a/src/primary/apps/radarr.py b/src/primary/apps/radarr.py index 818e39c2..6e4b58fc 100644 --- a/src/primary/apps/radarr.py +++ b/src/primary/apps/radarr.py @@ -4,6 +4,7 @@ from src.primary.utils.logger import get_logger from src.primary.state import get_state_file_path from src.primary.settings_manager import load_settings, get_ssl_verify_setting +from src.primary.apps.radarr.api import arr_request radarr_bp = Blueprint('radarr', __name__) radarr_logger = get_logger("radarr") @@ -23,101 +24,29 @@ def test_connection(): if not api_url or not api_key: return jsonify({"success": False, "message": "API URL and API Key are required"}), 400 - # Log the test attempt radarr_logger.info(f"Testing connection to Radarr API at {api_url}") - # First check if URL is properly formatted if not (api_url.startswith('http://') or api_url.startswith('https://')): error_msg = "API URL must start with http:// or https://" radarr_logger.error(error_msg) return jsonify({"success": False, "message": error_msg}), 400 - - # For Radarr, use api/v3 - api_base = "api/v3" - test_url = f"{api_url.rstrip('/')}/{api_base}/system/status" - headers = {'X-Api-Key': api_key} - # Get SSL verification setting verify_ssl = get_ssl_verify_setting() - if not verify_ssl: radarr_logger.debug("SSL verification disabled by user setting for connection test") try: - # Use a connection timeout separate from read timeout - response = requests.get(test_url, headers=headers, timeout=(10, api_timeout), verify=verify_ssl) - - # Log HTTP status code for diagnostic purposes - radarr_logger.debug(f"Radarr API status code: {response.status_code}") - - # Check HTTP status code - response.raise_for_status() - - # Ensure the response is valid JSON - try: - response_data = response.json() - - # We no longer save keys here since we use instances - # keys_manager.save_api_keys("radarr", api_url, api_key) - - radarr_logger.info(f"Successfully connected to Radarr API version: {response_data.get('version', 'unknown')}") - - # Return success with some useful information - return jsonify({ - "success": True, - "message": "Successfully connected to Radarr API", - "version": response_data.get('version', 'unknown') - }) - except ValueError: - error_msg = "Invalid JSON response from Radarr API" - radarr_logger.error(f"{error_msg}. Response content: {response.text[:200]}") + response_data = arr_request(api_url, api_key, api_timeout, "system/status", verify_ssl=verify_ssl) + if not response_data: + error_msg = "No response or invalid response from Radarr API" + radarr_logger.error(error_msg) return jsonify({"success": False, "message": error_msg}), 500 - - except requests.exceptions.Timeout as e: - error_msg = f"Connection timed out after {api_timeout} seconds" - radarr_logger.error(f"{error_msg}: {str(e)}") - return jsonify({"success": False, "message": error_msg}), 504 - - except requests.exceptions.ConnectionError as e: - error_msg = "Connection error - check hostname and port" - details = str(e) - # Check for common DNS resolution errors - if "Name or service not known" in details or "getaddrinfo failed" in details: - error_msg = "DNS resolution failed - check hostname" - # Check for common connection refused errors - elif "Connection refused" in details: - error_msg = "Connection refused - check if Radarr is running and the port is correct" - - radarr_logger.error(f"{error_msg}: {details}") - return jsonify({"success": False, "message": f"{error_msg}: {details}"}), 502 - - except requests.exceptions.RequestException as e: - error_message = f"Connection failed: {str(e)}" - - if hasattr(e, 'response') and e.response is not None: - status_code = e.response.status_code - - # Add specific messages based on common status codes - if status_code == 401: - error_message = "Authentication failed: Invalid API key" - elif status_code == 403: - error_message = "Access forbidden: Check API key permissions" - elif status_code == 404: - error_message = "API endpoint not found: Check API URL" - elif status_code >= 500: - error_message = f"Radarr server error (HTTP {status_code}): The Radarr server is experiencing issues" - - # Try to extract more error details if available - try: - error_details = e.response.json() - error_message += f" - {error_details.get('message', 'No details')}" - except ValueError: - if e.response.text: - error_message += f" - Response: {e.response.text[:200]}" - - radarr_logger.error(error_message) - return jsonify({"success": False, "message": error_message}), 500 - + radarr_logger.info(f"Successfully connected to Radarr API version: {response_data.get('version', 'unknown')}") + return jsonify({ + "success": True, + "message": "Successfully connected to Radarr API", + "version": response_data.get('version', 'unknown') + }) except Exception as e: error_msg = f"An unexpected error occurred: {str(e)}" radarr_logger.error(error_msg) From 5dbda495b9589a9bbc02a6a944bfecda57c6ea3c Mon Sep 17 00:00:00 2001 From: jharder01 Date: Wed, 21 May 2025 23:32:32 -0700 Subject: [PATCH 06/30] feat: add verify_ssl parameter to get_missing_episodes and update API request handling --- src/primary/apps/sonarr/api.py | 88 +++++++--------------------------- 1 file changed, 18 insertions(+), 70 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 965c1fd6..c0759aed 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -267,7 +267,7 @@ def command_status(api_url: str, api_key: str, api_timeout: int, command_id: Uni return response return {} -def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, series_id: Optional[int] = None) -> List[Dict[str, Any]]: +def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, series_id: Optional[int] = None, verify_ssl: Optional[bool] = None) -> List[Dict[str, Any]]: """Get missing episodes from Sonarr, handling pagination.""" endpoint = "wanted/missing" page = 1 @@ -298,91 +298,39 @@ def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored sonarr_logger.debug(f"Requesting missing episodes page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() # Check for HTTP errors (4xx or 5xx) - - if not response.content: - sonarr_logger.warning(f"Empty response for missing episodes page {page} (attempt {retry_count+1})") + response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items()), verify_ssl=verify_ssl) + if not response or "records" not in response: if retry_count < retries_per_page: retry_count += 1 time.sleep(retry_delay) continue else: - sonarr_logger.error(f"Giving up on empty response after {retries_per_page+1} attempts") - break # Exit the retry loop, continuing to next page or ending - - try: - data = response.json() - records = data.get('records', []) - total_records_on_page = len(records) - sonarr_logger.debug(f"Parsed {total_records_on_page} missing episode records from page {page}") - - if not records: # No more records found - sonarr_logger.debug(f"No more records found on page {page}. Stopping pagination.") - success = True # Mark as successful even though no records (might be legitimate) - break # Exit retry loop, then also exit pagination loop - - all_missing_episodes.extend(records) - - # Check if this was the last page - if total_records_on_page < page_size: - sonarr_logger.debug(f"Received {total_records_on_page} records (less than page size {page_size}). Last page.") - success = True - break # Exit retry loop, then also exit pagination loop - - # We got records and need to continue - mark success for this page + break + records = response.get('records', []) + total_records_on_page = len(records) + if not records: success = True - break # Exit retry loop, continue to next page - - except json.JSONDecodeError as e: - sonarr_logger.error(f"Failed to decode JSON response for missing episodes page {page} (attempt {retry_count+1}): {e}") - if retry_count < retries_per_page: - retry_count += 1 - time.sleep(retry_delay) - continue - else: - sonarr_logger.error(f"Giving up after {retries_per_page+1} failed JSON decode attempts") - break # Exit retry loop, moving to next page or ending - - except requests.exceptions.RequestException as e: - sonarr_logger.error(f"Request error for missing episodes page {page} (attempt {retry_count+1}): {e}") - if retry_count < retries_per_page: - retry_count += 1 - time.sleep(retry_delay) - continue - else: - sonarr_logger.error(f"Giving up on request after {retries_per_page+1} failed attempts") - break # Exit retry loop + break + all_missing_episodes.extend(records) + if total_records_on_page < page_size: + success = True + break + success = True + break except Exception as e: - sonarr_logger.error(f"Unexpected error for missing episodes page {page} (attempt {retry_count+1}): {e}") if retry_count < retries_per_page: retry_count += 1 time.sleep(retry_delay) continue else: - sonarr_logger.error(f"Giving up after unexpected error and {retries_per_page+1} attempts") - break # Exit retry loop - - # If we didn't succeed after all retries or there are no more records, stop pagination + break if not success or not records: break - - # Prepare for the next page page += 1 - - sonarr_logger.info(f"Total missing episodes fetched across all pages: {len(all_missing_episodes)}") - - # Apply monitored filter after fetching all pages if monitored_only: - original_count = len(all_missing_episodes) - filtered_missing = [ - ep for ep in all_missing_episodes - if ep.get('series', {}).get('monitored', False) and ep.get('monitored', False) - ] - sonarr_logger.debug(f"Filtered for monitored_only=True: {len(filtered_missing)} monitored episodes (out of {original_count} total)") + filtered_missing = [ep for ep in all_missing_episodes if ep.get('series', {}).get('monitored', False) and ep.get('monitored', False)] return filtered_missing else: - sonarr_logger.debug(f"Returning {len(all_missing_episodes)} episodes (monitored_only=False)") return all_missing_episodes def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]: @@ -414,7 +362,7 @@ def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, moni sonarr_logger.debug(f"Requesting cutoff unmet page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items()), verify_ssl=get_ssl_verify_setting()) sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}") response.raise_for_status() # Check for HTTP errors @@ -929,7 +877,7 @@ def get_cutoff_unmet_episodes_for_series(api_url: str, api_key: str, api_timeout sonarr_logger.debug(f"Requesting cutoff unmet page {page} for series {series_id} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items()), verify_ssl=get_ssl_verify_setting()) sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}") response.raise_for_status() # Check for HTTP errors From 51c7caeb86bc68ef67d57b23d0a2a0037c408182 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Thu, 22 May 2025 12:07:22 -0700 Subject: [PATCH 07/30] feat: refactor API request handling to use arr_request for multiple endpoints and improve SSL verification --- src/primary/apps/sonarr/api.py | 288 ++++++++++++++++----------------- 1 file changed, 140 insertions(+), 148 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index c0759aed..1f921fbc 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -294,11 +294,10 @@ def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored # Ensure proper URL construction with scheme base_url = api_url.rstrip('/') - url = f"{base_url}/api/v3/{endpoint.lstrip('/')}" sonarr_logger.debug(f"Requesting missing episodes page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items()), verify_ssl=verify_ssl) + response = arr_request(base_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items()), verify_ssl=verify_ssl) if not response or "records" not in response: if retry_count < retries_per_page: retry_count += 1 @@ -489,6 +488,7 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou Returns: A list of randomly selected cutoff unmet episodes """ + import random endpoint = "wanted/cutoff" page_size = 100 # Smaller page size to make the initial query faster @@ -498,14 +498,16 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou "pageSize": 1, "includeSeries": "true" # Include series info for filtering } - url = f"{api_url}/api/v3/{endpoint}" + param_str = "&".join(f"{k}={v}" for k, v in params.items()) + query_endpoint = f"{endpoint}?{param_str}" try: # Get total record count from a minimal query - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - data = response.json() - total_records = data.get('totalRecords', 0) + response = arr_request(api_url, api_key, api_timeout, query_endpoint, verify_ssl=get_ssl_verify_setting()) + if not response or "totalRecords" not in response: + sonarr_logger.warning("Empty or invalid response when getting cutoff unmet count") + return [] + total_records = response.get('totalRecords', 0) if total_records == 0: sonarr_logger.info("No cutoff unmet episodes found in Sonarr.") @@ -519,7 +521,6 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou return [] # Select a random page - import random random_page = random.randint(1, total_pages) sonarr_logger.info(f"Selected random page {random_page} of {total_pages} for quality upgrade selection") @@ -529,12 +530,13 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou "pageSize": page_size, "includeSeries": "true" } - - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - - data = response.json() - records = data.get('records', []) + param_str = "&".join(f"{k}={v}" for k, v in params.items()) + page_endpoint = f"{endpoint}?{param_str}" + response = arr_request(api_url, api_key, api_timeout, page_endpoint, verify_ssl=get_ssl_verify_setting()) + if not response or "records" not in response: + sonarr_logger.warning(f"Empty or invalid response when getting cutoff unmet episodes page {random_page}") + return [] + records = response.get('records', []) sonarr_logger.info(f"Retrieved {len(records)} episodes from page {random_page}") # Apply monitored filter if requested @@ -582,6 +584,7 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in Returns: A list of randomly selected missing episodes, up to the requested count """ + import random endpoint = "wanted/missing" page_size = 100 # Smaller page size for better performance retries = 2 @@ -593,104 +596,75 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in "pageSize": 1, "includeSeries": "true" # Include series info for filtering } - url = f"{api_url}/api/v3/{endpoint}" + if series_id is not None: + params["seriesId"] = series_id + param_str = "&".join(f"{k}={v}" for k, v in params.items()) + query_endpoint = f"{endpoint}?{param_str}" for attempt in range(retries + 1): try: # Get total record count from a minimal query sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - - if not response.content: - sonarr_logger.warning(f"Empty response when getting missing count (attempt {attempt+1})") + response = arr_request(api_url, api_key, api_timeout, query_endpoint, verify_ssl=get_ssl_verify_setting()) + if not response or "totalRecords" not in response: + sonarr_logger.warning(f"Empty or invalid response when getting missing count (attempt {attempt+1})") if attempt < retries: time.sleep(retry_delay) continue return [] + total_records = response.get('totalRecords', 0) + + if total_records == 0: + sonarr_logger.info("No missing episodes found in Sonarr.") + return [] - try: - data = response.json() - total_records = data.get('totalRecords', 0) - - if total_records == 0: - sonarr_logger.info("No missing episodes found in Sonarr.") - return [] - - # Calculate total pages with our desired page size - total_pages = (total_records + page_size - 1) // page_size - sonarr_logger.info(f"Found {total_records} total missing episodes across {total_pages} pages") - - if total_pages == 0: - return [] - - # Select a random page - import random - random_page = random.randint(1, total_pages) - sonarr_logger.info(f"Selected random page {random_page} of {total_pages} for missing episodes") - - # Get episodes from the random page - params = { - "page": random_page, - "pageSize": page_size, - "includeSeries": "true" - } - - if series_id is not None: - params["seriesId"] = series_id - - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - - if not response.content: - sonarr_logger.warning(f"Empty response when getting missing episodes page {random_page}") - return [] - - try: - data = response.json() - records = data.get('records', []) - sonarr_logger.info(f"Retrieved {len(records)} missing episodes from page {random_page}") - - # Apply monitored filter if requested - if monitored_only: - filtered_records = [ - ep for ep in records - if ep.get('series', {}).get('monitored', False) and ep.get('monitored', False) - ] - sonarr_logger.debug(f"Filtered to {len(filtered_records)} monitored missing episodes") - records = filtered_records - - # Select random episodes from this page - if len(records) > count: - selected_records = random.sample(records, count) - sonarr_logger.debug(f"Randomly selected {len(selected_records)} missing episodes from page {random_page}") - return selected_records - else: - # If we have fewer episodes than requested, return all of them - sonarr_logger.debug(f"Returning all {len(records)} missing episodes from page {random_page} (fewer than requested {count})") - return records - - except json.JSONDecodeError as jde: - sonarr_logger.error(f"Failed to decode JSON response for missing episodes page {random_page}: {str(jde)}") - if attempt < retries: - time.sleep(retry_delay) - continue - return [] - - except json.JSONDecodeError as jde: - sonarr_logger.error(f"Failed to decode JSON response for missing episodes count: {str(jde)}") - if attempt < retries: - time.sleep(retry_delay) - continue + # Calculate total pages with our desired page size + total_pages = (total_records + page_size - 1) // page_size + sonarr_logger.info(f"Found {total_records} total missing episodes across {total_pages} pages") + + if total_pages == 0: return [] - except requests.exceptions.RequestException as e: - sonarr_logger.error(f"Error getting missing episodes from Sonarr (attempt {attempt+1}): {str(e)}") - if attempt < retries: - time.sleep(retry_delay) - continue - return [] + # Select a random page + random_page = random.randint(1, total_pages) + sonarr_logger.info(f"Selected random page {random_page} of {total_pages} for missing episodes") + # Get episodes from the random page + params = { + "page": random_page, + "pageSize": page_size, + "includeSeries": "true" + } + if series_id is not None: + params["seriesId"] = series_id + param_str = "&".join(f"{k}={v}" for k, v in params.items()) + page_endpoint = f"{endpoint}?{param_str}" + response = arr_request(api_url, api_key, api_timeout, page_endpoint, verify_ssl=get_ssl_verify_setting()) + if not response or "records" not in response: + sonarr_logger.warning(f"Empty or invalid response when getting missing episodes page {random_page}") + return [] + records = response.get('records', []) + sonarr_logger.info(f"Retrieved {len(records)} missing episodes from page {random_page}") + + # Apply monitored filter if requested + if monitored_only: + filtered_records = [ + ep for ep in records + if ep.get('series', {}).get('monitored', False) and ep.get('monitored', False) + ] + sonarr_logger.debug(f"Filtered to {len(filtered_records)} monitored missing episodes") + records = filtered_records + + # Select random episodes from this page + if len(records) > count: + selected_records = random.sample(records, count) + sonarr_logger.debug(f"Randomly selected {len(selected_records)} missing episodes from page {random_page}") + return selected_records + else: + # If we have fewer episodes than requested, return all of them + sonarr_logger.debug(f"Returning all {len(records)} missing episodes from page {random_page} (fewer than requested {count})") + return records + except Exception as e: sonarr_logger.error(f"Unexpected error getting missing episodes (attempt {attempt+1}): {str(e)}", exc_info=True) if attempt < retries: @@ -708,19 +682,26 @@ def search_episode(api_url: str, api_key: str, api_timeout: int, episode_ids: Li sonarr_logger.warning("No episode IDs provided for search.") return None try: - endpoint = f"{api_url}/api/v3/command" payload = { "name": "EpisodeSearch", "episodeIds": episode_ids } - response = requests.post(endpoint, headers={"X-Api-Key": api_key}, json=payload, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - command_id = response.json().get('id') - sonarr_logger.info(f"Triggered Sonarr search for episode IDs: {episode_ids}. Command ID: {command_id}") - return command_id - except requests.exceptions.RequestException as e: - sonarr_logger.error(f"Error triggering Sonarr search for episode IDs {episode_ids}: {e}") - return None + response = arr_request( + api_url, + api_key, + api_timeout, + "command", + method="POST", + data=payload, + verify_ssl=get_ssl_verify_setting() + ) + if response and isinstance(response, dict): + command_id = response.get('id') + sonarr_logger.info(f"Triggered Sonarr search for episode IDs: {episode_ids}. Command ID: {command_id}") + return command_id + else: + sonarr_logger.error(f"Unexpected response from Sonarr when triggering episode search: {response}") + return None except Exception as e: sonarr_logger.error(f"An unexpected error occurred while triggering Sonarr search: {e}") return None @@ -728,15 +709,20 @@ def search_episode(api_url: str, api_key: str, api_timeout: int, episode_ids: Li def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: Union[int, str]) -> Optional[Dict[str, Any]]: """Get the status of a Sonarr command.""" try: - endpoint = f"{api_url}/api/v3/command/{command_id}" - response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - status = response.json() - sonarr_logger.debug(f"Checked Sonarr command status for ID {command_id}: {status.get('status')}") - return status - except requests.exceptions.RequestException as e: - sonarr_logger.error(f"Error getting Sonarr command status for ID {command_id}: {e}") - return None + response = arr_request( + api_url, + api_key, + api_timeout, + f"command/{command_id}", + method="GET", + verify_ssl=get_ssl_verify_setting() + ) + if response and isinstance(response, dict): + sonarr_logger.debug(f"Checked Sonarr command status for ID {command_id}: {response.get('status')}") + return response + else: + sonarr_logger.error(f"Unexpected response from Sonarr when getting command status: {response}") + return None except Exception as e: sonarr_logger.error(f"An unexpected error occurred while getting Sonarr command status: {e}") return None @@ -748,11 +734,10 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int for attempt in range(retries + 1): try: - endpoint = f"{api_url}/api/v3/queue?page=1&pageSize=1" # Just get total count, don't need records - response = requests.get(endpoint, headers={"X-Api-Key": api_key}, params={"includeSeries": "false"}, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - - if not response.content: + # Use arr_request to get queue info (page=1, pageSize=1 for just the count) + endpoint = "queue?page=1&pageSize=1&includeSeries=false" + response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=get_ssl_verify_setting()) + if not response: sonarr_logger.warning(f"Empty response when getting queue size (attempt {attempt+1}/{retries+1})") if attempt < retries: time.sleep(retry_delay) @@ -800,15 +785,20 @@ def refresh_series(api_url: str, api_key: str, api_timeout: int, series_id: int) def get_series_by_id(api_url: str, api_key: str, api_timeout: int, series_id: int) -> Optional[Dict[str, Any]]: """Get series details by ID from Sonarr.""" try: - endpoint = f"{api_url}/api/v3/series/{series_id}" - response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - series_data = response.json() - sonarr_logger.debug(f"Fetched details for Sonarr series ID: {series_id}") - return series_data - except requests.exceptions.RequestException as e: - sonarr_logger.error(f"Error getting Sonarr series details for ID {series_id}: {e}") - return None + response = arr_request( + api_url, + api_key, + api_timeout, + f"series/{series_id}", + method="GET", + verify_ssl=get_ssl_verify_setting() + ) + if response and isinstance(response, dict): + sonarr_logger.debug(f"Fetched details for Sonarr series ID: {series_id}") + return response + else: + sonarr_logger.error(f"Unexpected response from Sonarr when getting series details: {response}") + return None except Exception as e: sonarr_logger.error(f"An unexpected error occurred while getting Sonarr series details: {e}") return None @@ -816,20 +806,27 @@ def get_series_by_id(api_url: str, api_key: str, api_timeout: int, series_id: in def search_season(api_url: str, api_key: str, api_timeout: int, series_id: int, season_number: int) -> Optional[Union[int, str]]: """Trigger a search for a specific season in Sonarr.""" try: - endpoint = f"{api_url}/api/v3/command" payload = { "name": "SeasonSearch", "seriesId": series_id, "seasonNumber": season_number } - response = requests.post(endpoint, headers={"X-Api-Key": api_key}, json=payload, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - command_id = response.json().get('id') - sonarr_logger.info(f"Triggered Sonarr season search for series ID: {series_id}, season: {season_number}. Command ID: {command_id}") - return command_id - except requests.exceptions.RequestException as e: - sonarr_logger.error(f"Error triggering Sonarr season search for series ID {series_id}, season {season_number}: {e}") - return None + response = arr_request( + api_url, + api_key, + api_timeout, + "command", + method="POST", + data=payload, + verify_ssl=get_ssl_verify_setting() + ) + if response and isinstance(response, dict): + command_id = response.get('id') + sonarr_logger.info(f"Triggered Sonarr season search for series ID: {series_id}, season: {season_number}. Command ID: {command_id}") + return command_id + else: + sonarr_logger.error(f"Unexpected response from Sonarr when triggering season search: {response}") + return None except Exception as e: sonarr_logger.error(f"An unexpected error occurred while triggering Sonarr season search: {e}") return None @@ -1024,15 +1021,10 @@ def get_series_with_missing_episodes(api_url: str, api_key: str, api_timeout: in # Get all episodes for this series try: - endpoint = f"{api_url}/api/v3/episode?seriesId={series_id}" - response = requests.get(endpoint, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=get_ssl_verify_setting()) - response.raise_for_status() - - if not response.content: + endpoint = f"episode?seriesId={series_id}" + episodes = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=get_ssl_verify_setting()) + if not episodes: continue - - episodes = response.json() - # Filter to missing episodes missing_episodes = [ e for e in episodes From aa10d4bc9e00e7e49827ab39b8a7d987668392c1 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Thu, 22 May 2025 14:23:22 -0800 Subject: [PATCH 08/30] feat: update arr_request to ignore verify_ssl parameter in favor of global setting --- src/primary/apps/radarr/api.py | 32 ++++++++++----------- src/primary/apps/sonarr/api.py | 51 +++++++++++++++------------------- 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/primary/apps/radarr/api.py b/src/primary/apps/radarr/api.py index 2d78603a..421b040f 100644 --- a/src/primary/apps/radarr/api.py +++ b/src/primary/apps/radarr/api.py @@ -31,7 +31,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call (without /api/v3/) method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests - verify_ssl: Optional SSL verification flag. If not provided, falls back to global setting. + verify_ssl: Optional SSL verification flag. (THIS PARAMETER IS NOW IGNORED in favor of global setting) Returns: The parsed JSON response or None if the request failed @@ -53,11 +53,12 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met "User-Agent": "Huntarr/1.0 (https://github.com/plexguide/Huntarr.io)" } - # Get SSL verification setting - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() + # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: - radarr_logger.debug("SSL verification disabled by user setting") + # Updated log message to reflect that the global setting is used. + radarr_logger.debug("SSL verification disabled by global user setting") # Make the request based on the method if method.upper() == "GET": @@ -133,7 +134,7 @@ def get_movies_with_missing(api_url: str, api_key: str, api_timeout: int, monito A list of movie objects with missing files, or None if the request failed. """ # Use the updated arr_request with passed arguments - movies = arr_request(api_url, api_key, api_timeout, "movie", verify_ssl=None) + movies = arr_request(api_url, api_key, api_timeout, "movie") if movies is None: # Check for None explicitly, as an empty list is valid radarr_logger.error("Failed to retrieve movies from Radarr API.") return None @@ -167,14 +168,14 @@ def get_cutoff_unmet_movies(api_url: str, api_key: str, api_timeout: int, monito # We need to fetch all movies and filter locally, or use the /api/v3/movie/lookup endpoint if searching by TMDB/IMDB ID. # Fetching all movies is simpler for now. radarr_logger.debug("Fetching all movies to determine cutoff unmet status...") - movies = arr_request(api_url, api_key, api_timeout, "movie", verify_ssl=None) + movies = arr_request(api_url, api_key, api_timeout, "movie") if movies is None: radarr_logger.error("Failed to retrieve movies from Radarr API for cutoff check.") return None # Need quality profile information to determine cutoff unmet status. # Fetch quality profiles first. - profiles = arr_request(api_url, api_key, api_timeout, "qualityprofile", verify_ssl=None) + profiles = arr_request(api_url, api_key, api_timeout, "qualityprofile") if profiles is None: radarr_logger.error("Failed to retrieve quality profiles from Radarr API.") return None @@ -256,7 +257,7 @@ def movie_search(api_url: str, api_key: str, api_timeout: int, movie_ids: List[i } # Use the updated arr_request - response = arr_request(api_url, api_key, api_timeout, endpoint, method="POST", data=data, verify_ssl=None) + response = arr_request(api_url, api_key, api_timeout, endpoint, method="POST", data=data) if response and 'id' in response: command_id = response['id'] radarr_logger.debug(f"Triggered search for movie IDs: {movie_ids}. Command ID: {command_id}") @@ -265,7 +266,7 @@ def movie_search(api_url: str, api_key: str, api_timeout: int, movie_ids: List[i radarr_logger.error(f"Failed to trigger search command for movie IDs {movie_ids}. Response: {response}") return None -def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: """Check the connection to Radarr API.""" try: # Ensure api_url is properly formatted @@ -281,11 +282,10 @@ def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: O # Ensure URL doesn't end with a slash before adding the endpoint base_url = api_url.rstrip('/') full_url = f"{base_url}/api/v3/system/status" - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() - if not verify_ssl: - radarr_logger.debug("SSL verification disabled by user setting for connection check") - response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=verify_ssl) + effective_verify_ssl = get_ssl_verify_setting() + if not effective_verify_ssl: + radarr_logger.debug("SSL verification disabled by global user setting for connection check") + response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=effective_verify_ssl) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) radarr_logger.debug("Successfully connected to Radarr.") return True @@ -314,7 +314,7 @@ def wait_for_command(api_url: str, api_key: str, api_timeout: int, command_id: i """ attempts = 0 while attempts < max_attempts: - response = arr_request(api_url, api_key, api_timeout, f"command/{command_id}", verify_ssl=None) + response = arr_request(api_url, api_key, api_timeout, f"command/{command_id}") if response and 'state' in response: state = response['state'] if state == "completed": diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 1f921fbc..ae0a06a7 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -32,7 +32,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests - verify_ssl: Optional override for SSL verification + verify_ssl: Optional override for SSL verification (THIS PARAMETER IS NOW IGNORED in favor of global setting) Returns: The parsed JSON response or None if the request failed @@ -61,13 +61,14 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # Log the User-Agent for debugging sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") + + # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter + verify_ssl = get_ssl_verify_setting() - # Get SSL verification setting - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() if not verify_ssl: - sonarr_logger.debug("SSL verification disabled by user setting") - + # Updated log message to reflect that the global setting is used. + sonarr_logger.debug("SSL verification disabled by global user setting") + try: if method.upper() == "GET": response = session.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) @@ -129,8 +130,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: try: # Use a shorter timeout for a quick connection check quick_timeout = min(api_timeout, 15) - verify_ssl = get_ssl_verify_setting() - status = get_system_status(api_url, api_key, quick_timeout, verify_ssl=verify_ssl) + status = get_system_status(api_url, api_key, quick_timeout) if status and isinstance(status, dict) and 'version' in status: # Log success only if debug is enabled to avoid clutter sonarr_logger.debug(f"Connection check successful for {api_url}. Version: {status.get('version')}") @@ -144,7 +144,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: sonarr_logger.error(f"Connection check failed for {api_url}") return False -def get_system_status(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> Dict: +def get_system_status(api_url: str, api_key: str, api_timeout: int) -> Dict: """ Get Sonarr system status. @@ -152,12 +152,11 @@ def get_system_status(api_url: str, api_key: str, api_timeout: int, verify_ssl: api_url: The base URL of the Sonarr API api_key: The API key for authentication api_timeout: Timeout for the API request - verify_ssl: Optional override for SSL verification Returns: System status information or empty dict if request failed """ - response = arr_request(api_url, api_key, api_timeout, "system/status", verify_ssl=verify_ssl) + response = arr_request(api_url, api_key, api_timeout, "system/status") if response: return response return {} @@ -267,7 +266,7 @@ def command_status(api_url: str, api_key: str, api_timeout: int, command_id: Uni return response return {} -def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, series_id: Optional[int] = None, verify_ssl: Optional[bool] = None) -> List[Dict[str, Any]]: +def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, series_id: Optional[int] = None) -> List[Dict[str, Any]]: """Get missing episodes from Sonarr, handling pagination.""" endpoint = "wanted/missing" page = 1 @@ -297,7 +296,7 @@ def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored sonarr_logger.debug(f"Requesting missing episodes page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = arr_request(base_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items()), verify_ssl=verify_ssl) + response = arr_request(base_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items())) if not response or "records" not in response: if retry_count < retries_per_page: retry_count += 1 @@ -361,7 +360,7 @@ def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, moni sonarr_logger.debug(f"Requesting cutoff unmet page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items()), verify_ssl=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items())) sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}") response.raise_for_status() # Check for HTTP errors @@ -503,7 +502,7 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou try: # Get total record count from a minimal query - response = arr_request(api_url, api_key, api_timeout, query_endpoint, verify_ssl=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, query_endpoint) if not response or "totalRecords" not in response: sonarr_logger.warning("Empty or invalid response when getting cutoff unmet count") return [] @@ -532,7 +531,7 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou } param_str = "&".join(f"{k}={v}" for k, v in params.items()) page_endpoint = f"{endpoint}?{param_str}" - response = arr_request(api_url, api_key, api_timeout, page_endpoint, verify_ssl=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, page_endpoint) if not response or "records" not in response: sonarr_logger.warning(f"Empty or invalid response when getting cutoff unmet episodes page {random_page}") return [] @@ -605,7 +604,7 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in try: # Get total record count from a minimal query sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") - response = arr_request(api_url, api_key, api_timeout, query_endpoint, verify_ssl=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, query_endpoint) if not response or "totalRecords" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing count (attempt {attempt+1})") if attempt < retries: @@ -639,7 +638,7 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in params["seriesId"] = series_id param_str = "&".join(f"{k}={v}" for k, v in params.items()) page_endpoint = f"{endpoint}?{param_str}" - response = arr_request(api_url, api_key, api_timeout, page_endpoint, verify_ssl=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, page_endpoint) if not response or "records" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing episodes page {random_page}") return [] @@ -692,8 +691,7 @@ def search_episode(api_url: str, api_key: str, api_timeout: int, episode_ids: Li api_timeout, "command", method="POST", - data=payload, - verify_ssl=get_ssl_verify_setting() + data=payload ) if response and isinstance(response, dict): command_id = response.get('id') @@ -714,8 +712,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: api_key, api_timeout, f"command/{command_id}", - method="GET", - verify_ssl=get_ssl_verify_setting() + method="GET" ) if response and isinstance(response, dict): sonarr_logger.debug(f"Checked Sonarr command status for ID {command_id}: {response.get('status')}") @@ -736,7 +733,7 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int try: # Use arr_request to get queue info (page=1, pageSize=1 for just the count) endpoint = "queue?page=1&pageSize=1&includeSeries=false" - response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, endpoint) if not response: sonarr_logger.warning(f"Empty response when getting queue size (attempt {attempt+1}/{retries+1})") if attempt < retries: @@ -790,8 +787,7 @@ def get_series_by_id(api_url: str, api_key: str, api_timeout: int, series_id: in api_key, api_timeout, f"series/{series_id}", - method="GET", - verify_ssl=get_ssl_verify_setting() + method="GET" ) if response and isinstance(response, dict): sonarr_logger.debug(f"Fetched details for Sonarr series ID: {series_id}") @@ -817,8 +813,7 @@ def search_season(api_url: str, api_key: str, api_timeout: int, series_id: int, api_timeout, "command", method="POST", - data=payload, - verify_ssl=get_ssl_verify_setting() + data=payload ) if response and isinstance(response, dict): command_id = response.get('id') @@ -874,7 +869,7 @@ def get_cutoff_unmet_episodes_for_series(api_url: str, api_key: str, api_timeout sonarr_logger.debug(f"Requesting cutoff unmet page {page} for series {series_id} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items()), verify_ssl=get_ssl_verify_setting()) + response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items())) sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}") response.raise_for_status() # Check for HTTP errors From f33fd9d778c5705e6dfd2351f4b8a8f52096caac Mon Sep 17 00:00:00 2001 From: jharder01 Date: Thu, 22 May 2025 14:42:21 -0800 Subject: [PATCH 09/30] feat: update arr_request and check_connection to use global SSL verification setting --- src/primary/api.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/primary/api.py b/src/primary/api.py index 50d0e5b2..10f82657 100644 --- a/src/primary/api.py +++ b/src/primary/api.py @@ -7,6 +7,7 @@ import requests import time from typing import List, Dict, Any, Optional, Union +from primary.settings_manager import get_ssl_verify_setting from primary.utils.logger import logger, debug_log from primary.config import API_KEY, API_URL, API_TIMEOUT, COMMAND_WAIT_DELAY, COMMAND_WAIT_ATTEMPTS, APP_TYPE from src.primary.stats_manager import get_stats, reset_stats @@ -37,12 +38,19 @@ def arr_request(endpoint: str, method: str = "GET", data: Dict = None) -> Option "X-Api-Key": API_KEY, "Content-Type": "application/json" } + + # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter + verify_ssl = get_ssl_verify_setting() + + if not verify_ssl: + # Updated log message to reflect that the global setting is used. + logger.debug("SSL verification disabled by global user setting") try: if method.upper() == "GET": - response = session.get(url, headers=headers, timeout=API_TIMEOUT) + response = session.get(url, headers=headers, timeout=API_TIMEOUT, verify=verify_ssl) elif method.upper() == "POST": - response = session.post(url, headers=headers, json=data, timeout=API_TIMEOUT) + response = session.post(url, headers=headers, json=data, timeout=API_TIMEOUT, verify=verify_ssl) else: logger.error(f"Unsupported HTTP method: {method}") return None @@ -110,7 +118,14 @@ def check_connection(app_type: str = None) -> bool: } logger.debug(f"Testing connection with URL: {url}") - response = session.get(url, headers=headers, timeout=API_TIMEOUT) + + verify_ssl = get_ssl_verify_setting() + + if not verify_ssl: + # Updated log message to reflect that the global setting is used. + logger.debug("SSL verification disabled by global user setting") + + response = session.get(url, headers=headers, timeout=API_TIMEOUT, verify=verify_ssl) if response.status_code == 401: logger.error(f"Connection test failed: 401 Client Error: Unauthorized - Invalid API key for {current_app_type.title()}") From 6ad7263f3433560b92d425388f92292e6ce1933d Mon Sep 17 00:00:00 2001 From: jharder01 Date: Thu, 22 May 2025 22:12:57 -0500 Subject: [PATCH 10/30] feat: enhance connection check and add get_system_status function for improved API interaction --- src/primary/apps/radarr/api.py | 66 ++++++++++++++++++++-------------- src/primary/apps/sonarr/api.py | 4 +-- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/src/primary/apps/radarr/api.py b/src/primary/apps/radarr/api.py index 421b040f..fbb9e798 100644 --- a/src/primary/apps/radarr/api.py +++ b/src/primary/apps/radarr/api.py @@ -264,38 +264,52 @@ def movie_search(api_url: str, api_key: str, api_timeout: int, movie_ids: List[i return command_id else: radarr_logger.error(f"Failed to trigger search command for movie IDs {movie_ids}. Response: {response}") - return None + return None def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: - """Check the connection to Radarr API.""" - try: - # Ensure api_url is properly formatted - if not api_url: - radarr_logger.error("API URL is empty or not set") - return False - - # Make sure api_url has a scheme - if not (api_url.startswith('http://') or api_url.startswith('https://')): - radarr_logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://") - return False - - # Ensure URL doesn't end with a slash before adding the endpoint - base_url = api_url.rstrip('/') - full_url = f"{base_url}/api/v3/system/status" - effective_verify_ssl = get_ssl_verify_setting() - if not effective_verify_ssl: - radarr_logger.debug("SSL verification disabled by global user setting for connection check") - response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout, verify=effective_verify_ssl) - response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) - radarr_logger.debug("Successfully connected to Radarr.") - return True - except requests.exceptions.RequestException as e: - radarr_logger.error(f"Error connecting to Radarr: {e}") + """Checks connection by fetching system status.""" + if not api_url: + radarr_logger.error("API URL is empty or not set") return False + if not api_key: + radarr_logger.error("API Key is empty or not set") + return False + + try: + # Use a shorter timeout for a quick connection check + quick_timeout = min(api_timeout, 15) + status = get_system_status(api_url, api_key, quick_timeout) + if status and isinstance(status, dict) and 'version' in status: + # Log success only if debug is enabled to avoid clutter + radarr_logger.debug(f"Connection check successful for {api_url}. Version: {status.get('version')}") + return True + else: + # Log details if the status response was unexpected + radarr_logger.warning(f"Connection check for {api_url} returned unexpected status: {str(status)[:200]}") + return False except Exception as e: - radarr_logger.error(f"An unexpected error occurred during Radarr connection check: {e}") + # Error should have been logged by arr_request, just indicate failure + radarr_logger.error(f"Connection check failed for {api_url}") return False + +def get_system_status(api_url: str, api_key: str, api_timeout: int) -> Dict: + """ + Get Radarr system status. + + Args: + api_url: The base URL of the Radarr API + api_key: The API key for authentication + api_timeout: Timeout for the API request + + Returns: + System status information or empty dict if request failed + """ + response = arr_request(api_url, api_key, api_timeout, "system/status") + if response: + return response + return {} + def wait_for_command(api_url: str, api_key: str, api_timeout: int, command_id: int, delay_seconds: int = 1, max_attempts: int = 600) -> bool: """ diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index ae0a06a7..b5cc444f 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -32,7 +32,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests - verify_ssl: Optional override for SSL verification (THIS PARAMETER IS NOW IGNORED in favor of global setting) + verify_ssl: Optional SSL verification flag. (THIS PARAMETER IS NOW IGNORED in favor of global setting) Returns: The parsed JSON response or None if the request failed @@ -68,7 +68,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met if not verify_ssl: # Updated log message to reflect that the global setting is used. sonarr_logger.debug("SSL verification disabled by global user setting") - + try: if method.upper() == "GET": response = session.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) From c124673759af8d577c769130eea3329e68b9fae3 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Thu, 22 May 2025 22:40:01 -0500 Subject: [PATCH 11/30] feat: add debug logging for missing episodes count request in get_missing_episodes_random_page function --- src/primary/apps/sonarr/api.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index b5cc444f..1c886924 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -604,6 +604,10 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in try: # Get total record count from a minimal query sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") + sonarr_logger.debug(f"Requesting missing episodes count with endpoint: {query_endpoint}") + sonarr_logger.debug(f"Sonarr API URL: {api_url}") + sonarr_logger.debug(f"Sonarr API Key: {api_key}") + sonarr_logger.debug(f"Sonarr API Timeout: {api_timeout}") response = arr_request(api_url, api_key, api_timeout, query_endpoint) if not response or "totalRecords" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing count (attempt {attempt+1})") From 2f05780a0976d0c47751a6346f850a4c2bb32f4a Mon Sep 17 00:00:00 2001 From: jharder01 Date: Thu, 22 May 2025 23:24:13 -0500 Subject: [PATCH 12/30] feat: reintroduce retry logic and enhance SSL verification logging in get_missing_episodes_random_page function --- src/primary/apps/sonarr/api.py | 116 +++++---------------------------- 1 file changed, 15 insertions(+), 101 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 1c886924..3774a4f8 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -488,106 +488,10 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou A list of randomly selected cutoff unmet episodes """ import random - endpoint = "wanted/cutoff" - page_size = 100 # Smaller page size to make the initial query faster - - # First, make a request to get just the total record count (page 1 with size=1) - params = { - "page": 1, - "pageSize": 1, - "includeSeries": "true" # Include series info for filtering - } - param_str = "&".join(f"{k}={v}" for k, v in params.items()) - query_endpoint = f"{endpoint}?{param_str}" - - try: - # Get total record count from a minimal query - response = arr_request(api_url, api_key, api_timeout, query_endpoint) - if not response or "totalRecords" not in response: - sonarr_logger.warning("Empty or invalid response when getting cutoff unmet count") - return [] - total_records = response.get('totalRecords', 0) - - if total_records == 0: - sonarr_logger.info("No cutoff unmet episodes found in Sonarr.") - return [] - - # Calculate total pages with our desired page size - total_pages = (total_records + page_size - 1) // page_size - sonarr_logger.info(f"Found {total_records} total cutoff unmet episodes across {total_pages} pages") - - if total_pages == 0: - return [] - - # Select a random page - random_page = random.randint(1, total_pages) - sonarr_logger.info(f"Selected random page {random_page} of {total_pages} for quality upgrade selection") - - # Get episodes from the random page - params = { - "page": random_page, - "pageSize": page_size, - "includeSeries": "true" - } - param_str = "&".join(f"{k}={v}" for k, v in params.items()) - page_endpoint = f"{endpoint}?{param_str}" - response = arr_request(api_url, api_key, api_timeout, page_endpoint) - if not response or "records" not in response: - sonarr_logger.warning(f"Empty or invalid response when getting cutoff unmet episodes page {random_page}") - return [] - records = response.get('records', []) - sonarr_logger.info(f"Retrieved {len(records)} episodes from page {random_page}") - - # Apply monitored filter if requested - if monitored_only: - filtered_records = [ - ep for ep in records - if ep.get('series', {}).get('monitored', False) and ep.get('monitored', False) - ] - sonarr_logger.debug(f"Filtered to {len(filtered_records)} monitored episodes") - records = filtered_records - - # Select random episodes from this page - if len(records) > count: - selected_records = random.sample(records, count) - sonarr_logger.debug(f"Randomly selected {len(selected_records)} episodes from page {random_page}") - return selected_records - else: - # If we have fewer episodes than requested, return all of them - sonarr_logger.debug(f"Returning all {len(records)} episodes from page {random_page} (fewer than requested {count})") - return records - - except requests.exceptions.RequestException as e: - sonarr_logger.error(f"Error getting random cutoff unmet episodes from Sonarr: {str(e)}") - return [] - except json.JSONDecodeError as e: - sonarr_logger.error(f"Failed to decode JSON response for random cutoff selection: {str(e)}") - return [] - except Exception as e: - sonarr_logger.error(f"Unexpected error in random cutoff selection: {str(e)}", exc_info=True) - return [] - -def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int, series_id: Optional[int] = None) -> List[Dict[str, Any]]: - """ - Get a specified number of random missing episodes by selecting a random page. - This is more efficient for very large libraries. - - Args: - api_url: The base URL of the Sonarr API - api_key: The API key for authentication - api_timeout: Timeout for the API request - monitored_only: Whether to include only monitored episodes - count: How many episodes to return - series_id: Optional series ID to filter results for a specific series - - Returns: - A list of randomly selected missing episodes, up to the requested count - """ - import random endpoint = "wanted/missing" page_size = 100 # Smaller page size for better performance - retries = 2 - retry_delay = 3 + retries = 2 # <--- ADDED THIS LINE BACK + retry_delay = 3 # <--- ADDED THIS LINE BACK # First, make a request to get just the total record count (page 1 with size=1) params = { @@ -595,19 +499,24 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in "pageSize": 1, "includeSeries": "true" # Include series info for filtering } - if series_id is not None: - params["seriesId"] = series_id param_str = "&".join(f"{k}={v}" for k, v in params.items()) query_endpoint = f"{endpoint}?{param_str}" + # Import get_ssl_verify_setting here if not already available globally in this function + # from src.primary.settings_manager import get_ssl_verify_setting + for attempt in range(retries + 1): try: # Get total record count from a minimal query sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") sonarr_logger.debug(f"Requesting missing episodes count with endpoint: {query_endpoint}") sonarr_logger.debug(f"Sonarr API URL: {api_url}") - sonarr_logger.debug(f"Sonarr API Key: {api_key}") sonarr_logger.debug(f"Sonarr API Timeout: {api_timeout}") + + # Explicitly log the SSL verification setting just before the call + current_ssl_verify_setting = get_ssl_verify_setting() + sonarr_logger.debug(f"SSL verify setting before calling arr_request for count: {current_ssl_verify_setting}") + response = arr_request(api_url, api_key, api_timeout, query_endpoint) if not response or "totalRecords" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing count (attempt {attempt+1})") @@ -642,6 +551,11 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in params["seriesId"] = series_id param_str = "&".join(f"{k}={v}" for k, v in params.items()) page_endpoint = f"{endpoint}?{param_str}" + + # Explicitly log the SSL verification setting just before the call + current_ssl_verify_setting_page = get_ssl_verify_setting() + sonarr_logger.debug(f"SSL verify setting before calling arr_request for page {random_page}: {current_ssl_verify_setting_page}") + response = arr_request(api_url, api_key, api_timeout, page_endpoint) if not response or "records" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing episodes page {random_page}") From 2fb2e56b1bc66f9c63a88f3db0ec9da1a29c30c6 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Thu, 22 May 2025 23:42:23 -0500 Subject: [PATCH 13/30] feat: update arr_request to use global SSL verification setting and enhance logging; adjust page size and reintroduce retry logic in get_cutoff_unmet_episodes_random_page function --- src/primary/apps/sonarr/api.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 3774a4f8..7840173e 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -63,21 +63,21 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter - verify_ssl = get_ssl_verify_setting() + actual_verify_ssl_value = get_ssl_verify_setting() - if not verify_ssl: - # Updated log message to reflect that the global setting is used. - sonarr_logger.debug("SSL verification disabled by global user setting") + # Log the actual value of verify_ssl being used for this specific request + sonarr_logger.debug(f"arr_request: For URL {full_url}, verify_ssl is {actual_verify_ssl_value} (type: {type(actual_verify_ssl_value)})") + # THIS IS THE CRITICAL SECTION - Ensure actual_verify_ssl_value is used try: if method.upper() == "GET": - response = session.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) + response = session.get(full_url, headers=headers, timeout=api_timeout, verify=actual_verify_ssl_value) elif method.upper() == "POST": - response = session.post(full_url, headers=headers, json=data, timeout=api_timeout, verify=verify_ssl) + response = session.post(full_url, headers=headers, json=data, timeout=api_timeout, verify=actual_verify_ssl_value) elif method.upper() == "PUT": - response = session.put(full_url, headers=headers, json=data, timeout=api_timeout, verify=verify_ssl) + response = session.put(full_url, headers=headers, json=data, timeout=api_timeout, verify=actual_verify_ssl_value) elif method.upper() == "DELETE": - response = session.delete(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) + response = session.delete(full_url, headers=headers, timeout=api_timeout, verify=actual_verify_ssl_value) else: sonarr_logger.error(f"Unsupported HTTP method: {method}") return None @@ -489,9 +489,9 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou """ import random endpoint = "wanted/missing" - page_size = 100 # Smaller page size for better performance - retries = 2 # <--- ADDED THIS LINE BACK - retry_delay = 3 # <--- ADDED THIS LINE BACK + page_size = 1000 + retries = 2 + retry_delay = 3 # First, make a request to get just the total record count (page 1 with size=1) params = { @@ -502,9 +502,6 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou param_str = "&".join(f"{k}={v}" for k, v in params.items()) query_endpoint = f"{endpoint}?{param_str}" - # Import get_ssl_verify_setting here if not already available globally in this function - # from src.primary.settings_manager import get_ssl_verify_setting - for attempt in range(retries + 1): try: # Get total record count from a minimal query From 559d15abe1635b3b7fe44da0a947f890a8a9cbcb Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sat, 24 May 2025 21:36:37 -0600 Subject: [PATCH 14/30] feat: unify SSL verification handling in arr_request and enhance logging for clarity --- src/primary/apps/sonarr/api.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 7840173e..38f84cd7 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -63,21 +63,20 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter - actual_verify_ssl_value = get_ssl_verify_setting() + verify_ssl = get_ssl_verify_setting() - # Log the actual value of verify_ssl being used for this specific request - sonarr_logger.debug(f"arr_request: For URL {full_url}, verify_ssl is {actual_verify_ssl_value} (type: {type(actual_verify_ssl_value)})") + # Unconditionally log the SSL setting that will be used for this specific request + sonarr_logger.debug(f"arr_request: For URL {full_url}, effective SSL verification for requests call will be: {verify_ssl} (type: {type(verify_ssl)})") - # THIS IS THE CRITICAL SECTION - Ensure actual_verify_ssl_value is used try: if method.upper() == "GET": - response = session.get(full_url, headers=headers, timeout=api_timeout, verify=actual_verify_ssl_value) + response = session.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) elif method.upper() == "POST": - response = session.post(full_url, headers=headers, json=data, timeout=api_timeout, verify=actual_verify_ssl_value) + response = session.post(full_url, headers=headers, json=data, timeout=api_timeout, verify=verify_ssl) elif method.upper() == "PUT": - response = session.put(full_url, headers=headers, json=data, timeout=api_timeout, verify=actual_verify_ssl_value) + response = session.put(full_url, headers=headers, json=data, timeout=api_timeout, verify=verify_ssl) elif method.upper() == "DELETE": - response = session.delete(full_url, headers=headers, timeout=api_timeout, verify=actual_verify_ssl_value) + response = session.delete(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) else: sonarr_logger.error(f"Unsupported HTTP method: {method}") return None @@ -511,9 +510,9 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou sonarr_logger.debug(f"Sonarr API Timeout: {api_timeout}") # Explicitly log the SSL verification setting just before the call - current_ssl_verify_setting = get_ssl_verify_setting() - sonarr_logger.debug(f"SSL verify setting before calling arr_request for count: {current_ssl_verify_setting}") - + ssl_verify = get_ssl_verify_setting() + sonarr_logger.debug(f"get_missing_episodes_random_page: SSL verify for '{query_endpoint}' will be: {ssl_verify}") + response = arr_request(api_url, api_key, api_timeout, query_endpoint) if not response or "totalRecords" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing count (attempt {attempt+1})") From a3a85bcf63d013534ff0c82ae67c7d56f6d79fc0 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sat, 24 May 2025 22:16:57 -0600 Subject: [PATCH 15/30] feat: enhance logging in arr_request and get_ssl_verify_setting for better traceability --- src/primary/apps/sonarr/api.py | 10 ++++++---- src/primary/settings_manager.py | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 38f84cd7..c8b98a46 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -21,7 +21,8 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: + sonarr_logger.critical("SONARR API MODULE: arr_request CALLED! THIS IS THE CORRECT FUNCTION.") # <--- NEW DISTINCT LOG """ Make a request to the Sonarr API. @@ -32,7 +33,6 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests - verify_ssl: Optional SSL verification flag. (THIS PARAMETER IS NOW IGNORED in favor of global setting) Returns: The parsed JSON response or None if the request failed @@ -44,12 +44,12 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # Ensure api_url has a scheme if not (api_url.startswith('http://') or api_url.startswith('https://')): + sonarr_logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://") return None - # Construct the full URL properly full_url = f"{api_url.rstrip('/')}/api/v3/{endpoint.lstrip('/')}" - + sonarr_logger.debug(f"Making {method} request to: {full_url}") # Set up headers with User-Agent to identify Huntarr @@ -62,6 +62,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # Log the User-Agent for debugging sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") + sonarr_logger.debug(f"arr_request: About to call get_ssl_verify_setting() for {full_url}") + # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter verify_ssl = get_ssl_verify_setting() diff --git a/src/primary/settings_manager.py b/src/primary/settings_manager.py index 79a5da69..43997ec8 100644 --- a/src/primary/settings_manager.py +++ b/src/primary/settings_manager.py @@ -316,7 +316,10 @@ def get_ssl_verify_setting(): Returns: bool: True if SSL verification should be enabled (default), False otherwise """ - return get_advanced_setting("ssl_verify", True) + settings_logger.debug("SETTINGS_MANAGER: get_ssl_verify_setting CALLED") + value = get_advanced_setting("ssl_verify", True) + settings_logger.debug(f"SETTINGS_MANAGER: get_ssl_verify_setting is returning: {value} (type: {type(value)})") + return value # Example usage (for testing purposes, remove later) if __name__ == "__main__": From bcbb0bd7f6495b0ad07a13dc093181208e63e4aa Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sat, 24 May 2025 22:48:01 -0600 Subject: [PATCH 16/30] feat: streamline SSL verification handling in arr_request and settings_manager; enhance logging for better traceability --- src/primary/apps/sonarr/api.py | 166 ++++++++++++++++++++++++++++++-- src/primary/settings_manager.py | 5 +- 2 files changed, 158 insertions(+), 13 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index c8b98a46..b72737ec 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -22,7 +22,6 @@ session = requests.Session() def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: - sonarr_logger.critical("SONARR API MODULE: arr_request CALLED! THIS IS THE CORRECT FUNCTION.") # <--- NEW DISTINCT LOG """ Make a request to the Sonarr API. @@ -44,12 +43,12 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # Ensure api_url has a scheme if not (api_url.startswith('http://') or api_url.startswith('https://')): - sonarr_logger.error(f"Invalid URL format: {api_url} - URL must start with http:// or https://") return None + # Construct the full URL properly full_url = f"{api_url.rstrip('/')}/api/v3/{endpoint.lstrip('/')}" - + sonarr_logger.debug(f"Making {method} request to: {full_url}") # Set up headers with User-Agent to identify Huntarr @@ -62,12 +61,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # Log the User-Agent for debugging sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") - sonarr_logger.debug(f"arr_request: About to call get_ssl_verify_setting() for {full_url}") - - # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter + # Get SSL verification setting verify_ssl = get_ssl_verify_setting() - - # Unconditionally log the SSL setting that will be used for this specific request sonarr_logger.debug(f"arr_request: For URL {full_url}, effective SSL verification for requests call will be: {verify_ssl} (type: {type(verify_ssl)})") try: @@ -591,6 +586,159 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou sonarr_logger.error("All attempts to get missing episodes failed") return [] +def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int, series_id: Optional[int] = None) -> List[Dict[str, Any]]: + """ + Get a specified number of random missing episodes by selecting a random page. + This is much more efficient for very large libraries. + + Args: + api_url: The base URL of the Sonarr API + api_key: The API key for authentication + api_timeout: Timeout for the API request + monitored_only: Whether to include only monitored episodes + count: How many episodes to return + series_id: Optional series ID to filter by + + Returns: + A list of randomly selected missing episodes + """ + import random + endpoint = "wanted/missing" + page_size = 1000 + retries = 2 + retry_delay = 3 + + # Get SSL verification setting (important for the API requests) + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: + sonarr_logger.debug("SSL verification disabled by user setting") + + # First, make a request to get just the total record count (page 1 with size=1) + params = { + "page": 1, + "pageSize": 1, + "includeSeries": "true" # Include series info for filtering + } + if series_id is not None: + params["seriesId"] = series_id + + param_str = "&".join(f"{k}={v}" for k, v in params.items()) + query_endpoint = f"{endpoint}?{param_str}" + + for attempt in range(retries + 1): + try: + # Get total record count from a minimal query + sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") + + # Make the request directly using requests to ensure SSL verify setting is used + full_url = f"{api_url.rstrip('/')}/api/v3/{query_endpoint.lstrip('/')}" + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json", + "User-Agent": "Huntarr/1.0 (https://github.com/plexguide/Huntarr.io)" + } + + sonarr_logger.debug(f"Making GET request to: {full_url}") + sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") + sonarr_logger.debug(f"SSL verification disabled by user setting") + + response = session.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) + response.raise_for_status() + + if not response.content: + sonarr_logger.warning(f"Empty response when getting missing count (attempt {attempt+1})") + if attempt < retries: + time.sleep(retry_delay) + continue + return [] + + response_data = response.json() + if "totalRecords" not in response_data: + sonarr_logger.warning(f"Invalid response format when getting missing count (attempt {attempt+1})") + if attempt < retries: + time.sleep(retry_delay) + continue + return [] + + total_records = response_data.get('totalRecords', 0) + + if total_records == 0: + sonarr_logger.info("No missing episodes found in Sonarr.") + return [] + + # Calculate total pages with our desired page size + total_pages = (total_records + page_size - 1) // page_size + sonarr_logger.info(f"Found {total_records} total missing episodes across {total_pages} pages") + + if total_pages == 0: + return [] + + # Select a random page + random_page = random.randint(1, total_pages) + sonarr_logger.info(f"Selected random page {random_page} of {total_pages} for missing episodes") + + # Get episodes from the random page + params = { + "page": random_page, + "pageSize": page_size, + "includeSeries": "true" + } + if series_id is not None: + params["seriesId"] = series_id + + param_str = "&".join(f"{k}={v}" for k, v in params.items()) + page_endpoint = f"{endpoint}?{param_str}" + + # Make another direct request using requests to ensure SSL verify setting is used + full_page_url = f"{api_url.rstrip('/')}/api/v3/{page_endpoint.lstrip('/')}" + + sonarr_logger.debug(f"Making GET request to: {full_page_url}") + + page_response = session.get(full_page_url, headers=headers, timeout=api_timeout, verify=verify_ssl) + page_response.raise_for_status() + + if not page_response.content: + sonarr_logger.warning(f"Empty response when getting missing episodes page {random_page}") + return [] + + page_response_data = page_response.json() + if "records" not in page_response_data: + sonarr_logger.warning(f"Invalid response format when getting missing episodes page {random_page}") + return [] + + records = page_response_data.get('records', []) + sonarr_logger.info(f"Retrieved {len(records)} missing episodes from page {random_page}") + + # Apply monitored filter if requested + if monitored_only: + filtered_records = [ + ep for ep in records + if ep.get('series', {}).get('monitored', False) and ep.get('monitored', False) + ] + sonarr_logger.debug(f"Filtered to {len(filtered_records)} monitored missing episodes") + records = filtered_records + + # Select random episodes from this page + if len(records) > count: + selected_records = random.sample(records, count) + sonarr_logger.debug(f"Randomly selected {len(selected_records)} missing episodes from page {random_page}") + return selected_records + else: + # If we have fewer episodes than requested, return all of them + sonarr_logger.debug(f"Returning all {len(records)} missing episodes from page {random_page} (fewer than requested {count})") + return records + + except Exception as e: + sonarr_logger.error(f"Error getting missing episodes (attempt {attempt+1}): {str(e)}", exc_info=True) + if attempt < retries: + time.sleep(retry_delay) + continue + return [] + + # If we get here, all retries failed + sonarr_logger.error("All attempts to get missing episodes failed") + return [] + def search_episode(api_url: str, api_key: str, api_timeout: int, episode_ids: List[int]) -> Optional[Union[int, str]]: """Trigger a search for specific episodes in Sonarr.""" if not episode_ids: @@ -933,7 +1081,7 @@ def get_series_with_missing_episodes(api_url: str, api_key: str, api_timeout: in # Get all episodes for this series try: endpoint = f"episode?seriesId={series_id}" - episodes = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=get_ssl_verify_setting()) + episodes = arr_request(api_url, api_key, api_timeout, endpoint) if not episodes: continue # Filter to missing episodes diff --git a/src/primary/settings_manager.py b/src/primary/settings_manager.py index 43997ec8..79a5da69 100644 --- a/src/primary/settings_manager.py +++ b/src/primary/settings_manager.py @@ -316,10 +316,7 @@ def get_ssl_verify_setting(): Returns: bool: True if SSL verification should be enabled (default), False otherwise """ - settings_logger.debug("SETTINGS_MANAGER: get_ssl_verify_setting CALLED") - value = get_advanced_setting("ssl_verify", True) - settings_logger.debug(f"SETTINGS_MANAGER: get_ssl_verify_setting is returning: {value} (type: {type(value)})") - return value + return get_advanced_setting("ssl_verify", True) # Example usage (for testing purposes, remove later) if __name__ == "__main__": From 817ce3040234c15565bde7d3a2a32315ff36e59d Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sun, 25 May 2025 18:11:12 -0600 Subject: [PATCH 17/30] refactor: rename get_cutoff_unmet_episodes_random_page to get_missing_episodes_random_page for clarity; remove redundant code and enhance logging --- src/primary/apps/sonarr/api.py | 157 +-------------------------------- 1 file changed, 2 insertions(+), 155 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index b72737ec..fea32394 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -64,7 +64,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # Get SSL verification setting verify_ssl = get_ssl_verify_setting() sonarr_logger.debug(f"arr_request: For URL {full_url}, effective SSL verification for requests call will be: {verify_ssl} (type: {type(verify_ssl)})") - + try: if method.upper() == "GET": response = session.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) @@ -468,7 +468,7 @@ def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, moni sonarr_logger.debug(f"Returning {len(all_cutoff_unmet)} cutoff unmet episodes (monitored_only=False).") return all_cutoff_unmet -def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int) -> List[Dict[str, Any]]: +def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int) -> List[Dict[str, Any]]: """ Get a specified number of random cutoff unmet episodes by selecting a random page. This is much more efficient for very large libraries. @@ -586,159 +586,6 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou sonarr_logger.error("All attempts to get missing episodes failed") return [] -def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int, series_id: Optional[int] = None) -> List[Dict[str, Any]]: - """ - Get a specified number of random missing episodes by selecting a random page. - This is much more efficient for very large libraries. - - Args: - api_url: The base URL of the Sonarr API - api_key: The API key for authentication - api_timeout: Timeout for the API request - monitored_only: Whether to include only monitored episodes - count: How many episodes to return - series_id: Optional series ID to filter by - - Returns: - A list of randomly selected missing episodes - """ - import random - endpoint = "wanted/missing" - page_size = 1000 - retries = 2 - retry_delay = 3 - - # Get SSL verification setting (important for the API requests) - verify_ssl = get_ssl_verify_setting() - if not verify_ssl: - sonarr_logger.debug("SSL verification disabled by user setting") - - # First, make a request to get just the total record count (page 1 with size=1) - params = { - "page": 1, - "pageSize": 1, - "includeSeries": "true" # Include series info for filtering - } - if series_id is not None: - params["seriesId"] = series_id - - param_str = "&".join(f"{k}={v}" for k, v in params.items()) - query_endpoint = f"{endpoint}?{param_str}" - - for attempt in range(retries + 1): - try: - # Get total record count from a minimal query - sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") - - # Make the request directly using requests to ensure SSL verify setting is used - full_url = f"{api_url.rstrip('/')}/api/v3/{query_endpoint.lstrip('/')}" - headers = { - "X-Api-Key": api_key, - "Content-Type": "application/json", - "User-Agent": "Huntarr/1.0 (https://github.com/plexguide/Huntarr.io)" - } - - sonarr_logger.debug(f"Making GET request to: {full_url}") - sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") - sonarr_logger.debug(f"SSL verification disabled by user setting") - - response = session.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) - response.raise_for_status() - - if not response.content: - sonarr_logger.warning(f"Empty response when getting missing count (attempt {attempt+1})") - if attempt < retries: - time.sleep(retry_delay) - continue - return [] - - response_data = response.json() - if "totalRecords" not in response_data: - sonarr_logger.warning(f"Invalid response format when getting missing count (attempt {attempt+1})") - if attempt < retries: - time.sleep(retry_delay) - continue - return [] - - total_records = response_data.get('totalRecords', 0) - - if total_records == 0: - sonarr_logger.info("No missing episodes found in Sonarr.") - return [] - - # Calculate total pages with our desired page size - total_pages = (total_records + page_size - 1) // page_size - sonarr_logger.info(f"Found {total_records} total missing episodes across {total_pages} pages") - - if total_pages == 0: - return [] - - # Select a random page - random_page = random.randint(1, total_pages) - sonarr_logger.info(f"Selected random page {random_page} of {total_pages} for missing episodes") - - # Get episodes from the random page - params = { - "page": random_page, - "pageSize": page_size, - "includeSeries": "true" - } - if series_id is not None: - params["seriesId"] = series_id - - param_str = "&".join(f"{k}={v}" for k, v in params.items()) - page_endpoint = f"{endpoint}?{param_str}" - - # Make another direct request using requests to ensure SSL verify setting is used - full_page_url = f"{api_url.rstrip('/')}/api/v3/{page_endpoint.lstrip('/')}" - - sonarr_logger.debug(f"Making GET request to: {full_page_url}") - - page_response = session.get(full_page_url, headers=headers, timeout=api_timeout, verify=verify_ssl) - page_response.raise_for_status() - - if not page_response.content: - sonarr_logger.warning(f"Empty response when getting missing episodes page {random_page}") - return [] - - page_response_data = page_response.json() - if "records" not in page_response_data: - sonarr_logger.warning(f"Invalid response format when getting missing episodes page {random_page}") - return [] - - records = page_response_data.get('records', []) - sonarr_logger.info(f"Retrieved {len(records)} missing episodes from page {random_page}") - - # Apply monitored filter if requested - if monitored_only: - filtered_records = [ - ep for ep in records - if ep.get('series', {}).get('monitored', False) and ep.get('monitored', False) - ] - sonarr_logger.debug(f"Filtered to {len(filtered_records)} monitored missing episodes") - records = filtered_records - - # Select random episodes from this page - if len(records) > count: - selected_records = random.sample(records, count) - sonarr_logger.debug(f"Randomly selected {len(selected_records)} missing episodes from page {random_page}") - return selected_records - else: - # If we have fewer episodes than requested, return all of them - sonarr_logger.debug(f"Returning all {len(records)} missing episodes from page {random_page} (fewer than requested {count})") - return records - - except Exception as e: - sonarr_logger.error(f"Error getting missing episodes (attempt {attempt+1}): {str(e)}", exc_info=True) - if attempt < retries: - time.sleep(retry_delay) - continue - return [] - - # If we get here, all retries failed - sonarr_logger.error("All attempts to get missing episodes failed") - return [] - def search_episode(api_url: str, api_key: str, api_timeout: int, episode_ids: List[int]) -> Optional[Union[int, str]]: """Trigger a search for specific episodes in Sonarr.""" if not episode_ids: From cb4a8608a9e29f6e6c003bb133bf4155c52add15 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sun, 25 May 2025 18:17:11 -0600 Subject: [PATCH 18/30] feat: add debug logging for random selection mode in process_missing_episodes_mode --- src/primary/apps/sonarr/missing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/primary/apps/sonarr/missing.py b/src/primary/apps/sonarr/missing.py index c30b000e..8bfcd009 100644 --- a/src/primary/apps/sonarr/missing.py +++ b/src/primary/apps/sonarr/missing.py @@ -86,6 +86,7 @@ def process_missing_episodes_mode( # Always use random selection for missing episodes sonarr_logger.info(f"Using random selection for missing episodes") + sonarr_logger.debug(f"TESTING: Random selection mode for missing episodes with hunt_missing_items={hunt_missing_items}") episodes_to_search = sonarr_api.get_missing_episodes_random_page( api_url, api_key, api_timeout, monitored_only, hunt_missing_items) From 019ac1db11bcff66c1ac280e83483422cc48a13b Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sun, 25 May 2025 19:33:54 -0600 Subject: [PATCH 19/30] feat: update logging level for random selection mode in process_missing_episodes_mode --- src/primary/apps/sonarr/api.py | 43 +++++++++++------------------- src/primary/apps/sonarr/missing.py | 2 +- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index fea32394..d98e5b24 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -21,7 +21,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None) -> Any: """ Make a request to the Sonarr API. @@ -32,6 +32,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests + params: Optional query parameters for GET requests Returns: The parsed JSON response or None if the request failed @@ -67,7 +68,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met try: if method.upper() == "GET": - response = session.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) + response = session.get(full_url, headers=headers, params=params, timeout=api_timeout, verify=verify_ssl) elif method.upper() == "POST": response = session.post(full_url, headers=headers, json=data, timeout=api_timeout, verify=verify_ssl) elif method.upper() == "PUT": @@ -227,22 +228,15 @@ def get_calendar(api_url: str, api_key: str, api_timeout: int, start_date: Optio Returns: Calendar information or empty list if request failed """ - params = [] - + params = {} if start_date: - params.append(f"start={start_date}") - + params['start'] = start_date if end_date: - params.append(f"end={end_date}") - + params['end'] = end_date endpoint = "calendar" - if params: - endpoint = f"{endpoint}?{'&'.join(params)}" - - response = arr_request(api_url, api_key, api_timeout, endpoint) - if response: - return response - return [] + + response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) + return response if response else [] def command_status(api_url: str, api_key: str, api_timeout: int, command_id: Union[int, str]) -> Dict: """ @@ -292,7 +286,7 @@ def get_missing_episodes(api_url: str, api_key: str, api_timeout: int, monitored sonarr_logger.debug(f"Requesting missing episodes page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = arr_request(base_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items())) + response = arr_request(base_url, api_key, api_timeout, endpoint, params=params) if not response or "records" not in response: if retry_count < retries_per_page: retry_count += 1 @@ -356,7 +350,7 @@ def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, moni sonarr_logger.debug(f"Requesting cutoff unmet page {page} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items())) + response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}") response.raise_for_status() # Check for HTTP errors @@ -495,22 +489,20 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in "pageSize": 1, "includeSeries": "true" # Include series info for filtering } - param_str = "&".join(f"{k}={v}" for k, v in params.items()) - query_endpoint = f"{endpoint}?{param_str}" for attempt in range(retries + 1): try: # Get total record count from a minimal query sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") - sonarr_logger.debug(f"Requesting missing episodes count with endpoint: {query_endpoint}") + sonarr_logger.debug(f"Requesting missing episodes count with endpoint: {endpoint} with params {params}") sonarr_logger.debug(f"Sonarr API URL: {api_url}") sonarr_logger.debug(f"Sonarr API Timeout: {api_timeout}") # Explicitly log the SSL verification setting just before the call ssl_verify = get_ssl_verify_setting() - sonarr_logger.debug(f"get_missing_episodes_random_page: SSL verify for '{query_endpoint}' will be: {ssl_verify}") + sonarr_logger.debug(f"get_missing_episodes_random_page: SSL verify for '{endpoint}' will be: {ssl_verify}") - response = arr_request(api_url, api_key, api_timeout, query_endpoint) + response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) if not response or "totalRecords" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing count (attempt {attempt+1})") if attempt < retries: @@ -542,14 +534,12 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in } if series_id is not None: params["seriesId"] = series_id - param_str = "&".join(f"{k}={v}" for k, v in params.items()) - page_endpoint = f"{endpoint}?{param_str}" # Explicitly log the SSL verification setting just before the call current_ssl_verify_setting_page = get_ssl_verify_setting() sonarr_logger.debug(f"SSL verify setting before calling arr_request for page {random_page}: {current_ssl_verify_setting_page}") - response = arr_request(api_url, api_key, api_timeout, page_endpoint) + response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) if not response or "records" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing episodes page {random_page}") return [] @@ -776,11 +766,10 @@ def get_cutoff_unmet_episodes_for_series(api_url: str, api_key: str, api_timeout "sortDir": "asc", "seriesId": series_id # Filter by series ID - this limits results to only this series } - url = f"{api_url}/api/v3/{endpoint}" sonarr_logger.debug(f"Requesting cutoff unmet page {page} for series {series_id} (attempt {retry_count+1}/{retries_per_page+1})") try: - response = arr_request(api_url, api_key, api_timeout, f"{endpoint}?" + "&".join(f"{k}={v}" for k,v in params.items())) + response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) sonarr_logger.debug(f"Sonarr API response status code for cutoff unmet page {page}: {response.status_code}") response.raise_for_status() # Check for HTTP errors diff --git a/src/primary/apps/sonarr/missing.py b/src/primary/apps/sonarr/missing.py index 8bfcd009..2ba5e89b 100644 --- a/src/primary/apps/sonarr/missing.py +++ b/src/primary/apps/sonarr/missing.py @@ -86,7 +86,7 @@ def process_missing_episodes_mode( # Always use random selection for missing episodes sonarr_logger.info(f"Using random selection for missing episodes") - sonarr_logger.debug(f"TESTING: Random selection mode for missing episodes with hunt_missing_items={hunt_missing_items}") + sonarr_logger.info(f"TESTING: Random selection mode for missing episodes with hunt_missing_items={hunt_missing_items}") episodes_to_search = sonarr_api.get_missing_episodes_random_page( api_url, api_key, api_timeout, monitored_only, hunt_missing_items) From 7ba36bb3d2198101019a92e39dc0f7a7c447ab28 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sun, 25 May 2025 21:10:14 -0600 Subject: [PATCH 20/30] feat: enhance random episode selection and streamline connection checks --- src/primary/apps/sonarr/api.py | 11 ++++------- src/primary/apps/sonarr/missing.py | 3 +-- src/primary/background.py | 9 +-------- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index d98e5b24..5623e897 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -10,6 +10,7 @@ import time import datetime import traceback +import random from typing import List, Dict, Any, Optional, Union, Callable # Correct the import path from src.primary.utils.logger import get_logger @@ -462,7 +463,7 @@ def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, moni sonarr_logger.debug(f"Returning {len(all_cutoff_unmet)} cutoff unmet episodes (monitored_only=False).") return all_cutoff_unmet -def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int) -> List[Dict[str, Any]]: +def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int, series_id: Optional[int] = None) -> List[Dict[str, Any]]: """ Get a specified number of random cutoff unmet episodes by selecting a random page. This is much more efficient for very large libraries. @@ -473,11 +474,9 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in api_timeout: Timeout for the API request monitored_only: Whether to include only monitored episodes count: How many episodes to return - - Returns: + Returns: A list of randomly selected cutoff unmet episodes """ - import random endpoint = "wanted/missing" page_size = 1000 retries = 2 @@ -892,10 +891,8 @@ def get_series_with_missing_episodes(api_url: str, api_key: str, api_timeout: in sonarr_logger.info(f"Filtered from {len(all_series)} total series to {len(filtered_series)} monitored series") else: filtered_series = all_series - - # Apply random selection if requested + # Apply random selection if requested if random_mode: - import random sonarr_logger.info(f"Using RANDOM selection mode for missing episodes") random.shuffle(filtered_series) else: diff --git a/src/primary/apps/sonarr/missing.py b/src/primary/apps/sonarr/missing.py index d02b70c9..c10d17e9 100644 --- a/src/primary/apps/sonarr/missing.py +++ b/src/primary/apps/sonarr/missing.py @@ -83,8 +83,7 @@ def process_missing_episodes_mode( ) -> bool: """Process missing episodes in episode mode (original implementation).""" processed_any = False - - # Always use random selection for missing episodes + # Always use random selection for missing episodes sonarr_logger.info(f"Using random selection for missing episodes") sonarr_logger.info(f"TESTING: Random selection mode for missing episodes with hunt_missing_items={hunt_missing_items}") episodes_to_search = sonarr_api.get_missing_episodes_random_page( diff --git a/src/primary/background.py b/src/primary/background.py index f7ad244f..67c50c13 100644 --- a/src/primary/background.py +++ b/src/primary/background.py @@ -227,14 +227,7 @@ def app_specific_loop(app_type: str) -> None: try: # Use instance details for connection check app_logger.debug(f"Checking connection to {app_type} instance '{instance_name}' at {api_url} with timeout {api_timeout}s") - # Determine SSL verify setting: instance-level, app-level, or global - verify_ssl = instance_details.get("ssl_verify") - if verify_ssl is None: - verify_ssl = app_settings.get("ssl_verify") - if verify_ssl is None: - from src.primary.settings_manager import get_ssl_verify_setting - verify_ssl = get_ssl_verify_setting() - connected = check_connection(api_url, api_key, api_timeout=api_timeout, verify_ssl=verify_ssl) + connected = check_connection(api_url, api_key, api_timeout=api_timeout) if not connected: app_logger.warning(f"Failed to connect to {app_type} instance '{instance_name}' at {api_url}. Skipping.") continue From 7cbb3082f405a093b7547665e19c1dee188b1945 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sun, 25 May 2025 21:48:07 -0600 Subject: [PATCH 21/30] refactor: improve docstring formatting and enhance comments for clarity in episode processing --- src/primary/apps/sonarr/api.py | 6 ++++-- src/primary/apps/sonarr/missing.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 5623e897..1cc8ebca 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -474,7 +474,8 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in api_timeout: Timeout for the API request monitored_only: Whether to include only monitored episodes count: How many episodes to return - Returns: + + Returns: A list of randomly selected cutoff unmet episodes """ endpoint = "wanted/missing" @@ -891,7 +892,8 @@ def get_series_with_missing_episodes(api_url: str, api_key: str, api_timeout: in sonarr_logger.info(f"Filtered from {len(all_series)} total series to {len(filtered_series)} monitored series") else: filtered_series = all_series - # Apply random selection if requested + + # Apply random selection if requested if random_mode: sonarr_logger.info(f"Using RANDOM selection mode for missing episodes") random.shuffle(filtered_series) diff --git a/src/primary/apps/sonarr/missing.py b/src/primary/apps/sonarr/missing.py index c10d17e9..2cbeb2bb 100644 --- a/src/primary/apps/sonarr/missing.py +++ b/src/primary/apps/sonarr/missing.py @@ -83,9 +83,9 @@ def process_missing_episodes_mode( ) -> bool: """Process missing episodes in episode mode (original implementation).""" processed_any = False - # Always use random selection for missing episodes + + # Always use random selection for missing episodes sonarr_logger.info(f"Using random selection for missing episodes") - sonarr_logger.info(f"TESTING: Random selection mode for missing episodes with hunt_missing_items={hunt_missing_items}") episodes_to_search = sonarr_api.get_missing_episodes_random_page( api_url, api_key, api_timeout, monitored_only, hunt_missing_items) From 4d735a2ee998105aaa0347e23c2cabb95ca88001 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Mon, 26 May 2025 19:36:34 -0600 Subject: [PATCH 22/30] refactor: remove unused SSL verification parameter from API request functions --- src/primary/api.py | 7 ++- src/primary/apps/eros/api.py | 35 ++++++------ src/primary/apps/lidarr/api.py | 33 ++++-------- src/primary/apps/radarr.py | 93 ++++++++++++++++++++++++++++---- src/primary/apps/radarr/api.py | 15 +++--- src/primary/apps/readarr/api.py | 19 +++---- src/primary/apps/sonarr/api.py | 10 +--- src/primary/apps/whisparr/api.py | 35 ++++++------ 8 files changed, 147 insertions(+), 100 deletions(-) diff --git a/src/primary/api.py b/src/primary/api.py index 10f82657..edce114e 100644 --- a/src/primary/api.py +++ b/src/primary/api.py @@ -39,11 +39,10 @@ def arr_request(endpoint: str, method: str = "GET", data: Dict = None) -> Option "Content-Type": "application/json" } - # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter + # Get SSL verification setting verify_ssl = get_ssl_verify_setting() if not verify_ssl: - # Updated log message to reflect that the global setting is used. logger.debug("SSL verification disabled by global user setting") try: @@ -119,10 +118,10 @@ def check_connection(app_type: str = None) -> bool: logger.debug(f"Testing connection with URL: {url}") + # Get SSL verification setting verify_ssl = get_ssl_verify_setting() - + if not verify_ssl: - # Updated log message to reflect that the global setting is used. logger.debug("SSL verification disabled by global user setting") response = session.get(url, headers=headers, timeout=API_TIMEOUT, verify=verify_ssl) diff --git a/src/primary/apps/eros/api.py b/src/primary/apps/eros/api.py index a7af1e19..427dd523 100644 --- a/src/primary/apps/eros/api.py +++ b/src/primary/apps/eros/api.py @@ -22,7 +22,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: """ Make a request to the Eros API. @@ -33,7 +33,6 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests - verify_ssl: Whether to verify SSL certificates (overrides global setting if provided) Returns: The parsed JSON response or None if the request failed @@ -108,7 +107,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met eros_logger.error(f"Unexpected error during API request: {e}") return None -def get_download_queue_size(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> int: +def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int: """ Get the current size of the download queue. @@ -120,7 +119,7 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int, verify Returns: The number of items in the download queue, or -1 if the request failed """ - response = arr_request(api_url, api_key, api_timeout, "queue", verify_ssl=verify_ssl) + response = arr_request(api_url, api_key, api_timeout, "queue") if response is None: return -1 @@ -134,7 +133,7 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int, verify else: return -1 -def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie", verify_ssl: Optional[bool] = None) -> List[Dict[str, Any]]: +def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie") -> List[Dict[str, Any]]: """ Get a list of items with missing files (not downloaded/available). @@ -155,7 +154,7 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor # In movie mode, we get all movies and filter for ones without files endpoint = "movie" - response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) + response = arr_request(api_url, api_key, api_timeout, endpoint) if response is None: return None @@ -174,12 +173,12 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor # First check if the movie-scene endpoint exists endpoint = "scene/missing?pageSize=1000" - response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) + response = arr_request(api_url, api_key, api_timeout, endpoint) if response is None: # Fallback to regular movie filtering if scene endpoint doesn't exist eros_logger.warning("Scene endpoint not available, falling back to movie mode") - return get_items_with_missing(api_url, api_key, api_timeout, monitored_only, "movie", verify_ssl=verify_ssl) + return get_items_with_missing(api_url, api_key, api_timeout, monitored_only, "movie") # Extract the scenes items = [] @@ -205,7 +204,7 @@ def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitor eros_logger.error(f"Error retrieving missing items: {str(e)}") return None -def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, verify_ssl: Optional[bool] = None) -> List[Dict[str, Any]]: +def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]: """ Get a list of items that don't meet their quality profile cutoff. @@ -224,7 +223,7 @@ def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitor # Endpoint endpoint = "wanted/cutoff?pageSize=1000&sortKey=airDateUtc&sortDirection=descending" - response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) + response = arr_request(api_url, api_key, api_timeout, endpoint) if response is None: return None @@ -249,7 +248,7 @@ def get_cutoff_unmet_items(api_url: str, api_key: str, api_timeout: int, monitor eros_logger.error(f"Error retrieving cutoff unmet items: {str(e)}") return None -def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie", verify_ssl: Optional[bool] = None) -> List[Dict[str, Any]]: +def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, search_mode: str = "movie") -> List[Dict[str, Any]]: """ Get a list of items that can be upgraded to better quality. @@ -270,7 +269,7 @@ def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored # In movie mode, we get all movies and filter for ones that have files but need quality upgrades endpoint = "movie" - response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) + response = arr_request(api_url, api_key, api_timeout, endpoint) if response is None: return None @@ -288,12 +287,12 @@ def get_quality_upgrades(api_url: str, api_key: str, api_timeout: int, monitored # In scene mode, try to use scene-specific endpoints endpoint = "scene/cutoff?pageSize=1000" - response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) + response = arr_request(api_url, api_key, api_timeout, endpoint) if response is None: # Fallback to regular movie filtering if scene endpoint doesn't exist eros_logger.warning("Scene cutoff endpoint not available, falling back to movie mode") - return get_quality_upgrades(api_url, api_key, api_timeout, monitored_only, "movie", verify_ssl=verify_ssl) + return get_quality_upgrades(api_url, api_key, api_timeout, monitored_only, "movie") # Extract the scenes items = [] @@ -433,7 +432,7 @@ def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int eros_logger.error(f"Error searching for movies: {str(e)}") return None -def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int, verify_ssl: Optional[bool] = None) -> Optional[Dict]: +def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: int) -> Optional[Dict]: """ Get the status of a specific command. @@ -454,7 +453,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: command_endpoint = f"command/{command_id}" # Make the API request - result = arr_request(api_url, api_key, api_timeout, command_endpoint, verify_ssl=verify_ssl) + result = arr_request(api_url, api_key, api_timeout, command_endpoint) if result: eros_logger.debug(f"Command {command_id} status: {result.get('status', 'unknown')}") @@ -467,7 +466,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: eros_logger.error(f"Error getting command status for ID {command_id}: {e}") return None -def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: """ Check the connection to Whisparr V3 API. @@ -484,7 +483,7 @@ def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: O eros_logger.debug(f"Checking connection to Whisparr V3 instance at {api_url}") endpoint = "system/status" - response = arr_request(api_url, api_key, api_timeout, endpoint, verify_ssl=verify_ssl) + response = arr_request(api_url, api_key, api_timeout, endpoint) if response is not None: # Get the version information if available diff --git a/src/primary/apps/lidarr/api.py b/src/primary/apps/lidarr/api.py index b178648d..4e84ebf0 100644 --- a/src/primary/apps/lidarr/api.py +++ b/src/primary/apps/lidarr/api.py @@ -21,7 +21,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None, verify_ssl: Optional[bool] = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None) -> Any: """ Make a request to the Lidarr API. @@ -33,7 +33,6 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met method: HTTP method (GET, POST, PUT, DELETE) data: Optional data to send with the request params: Optional query parameters - verify_ssl: Optional override for SSL verification Returns: The JSON response from the API, or None if the request failed @@ -60,8 +59,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met lidarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") # Get SSL verification setting - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: lidarr_logger.debug("SSL verification disabled by user setting") @@ -118,7 +117,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # --- Specific API Functions --- -def get_system_status(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> Dict: +def get_system_status(api_url: str, api_key: str, api_timeout: int) -> Dict: """ Get Lidarr system status. @@ -126,27 +125,16 @@ def get_system_status(api_url: str, api_key: str, api_timeout: int, verify_ssl: api_url: The base URL of the Lidarr API api_key: The API key for authentication api_timeout: Timeout for the API request - verify_ssl: Optional override for SSL verification Returns: System status information or empty dict if request failed """ - # If verify_ssl is not provided, get it from settings - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() - - # Log whether SSL verification is being used - if not verify_ssl: - lidarr_logger.debug("SSL verification disabled for system status check") - + try: # For Lidarr, use V1 API endpoint = f"{api_url.rstrip('/')}/api/v1/system/status" - headers = {"X-Api-Key": api_key} - # Execute the request with SSL verification setting - response = requests.get(endpoint, headers=headers, timeout=api_timeout, verify=verify_ssl) - response.raise_for_status() + response = arr_request(api_url, api_key, api_timeout, "system/status", method="GET") # Parse and return the result return response.json() @@ -154,7 +142,7 @@ def get_system_status(api_url: str, api_key: str, api_timeout: int, verify_ssl: lidarr_logger.error(f"Error getting system status: {str(e)}") return {} -def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: """Checks connection by fetching system status.""" if not api_url: lidarr_logger.error("API URL is empty or not set") @@ -166,11 +154,8 @@ def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: O try: # Use a shorter timeout for a quick connection check quick_timeout = min(api_timeout, 15) - - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() - - status = get_system_status(api_url, api_key, quick_timeout, verify_ssl) + + status = get_system_status(api_url, api_key, quick_timeout) if status and isinstance(status, dict) and 'version' in status: # Log success only if debug is enabled to avoid clutter lidarr_logger.debug(f"Connection check successful for {api_url}. Version: {status.get('version')}") diff --git a/src/primary/apps/radarr.py b/src/primary/apps/radarr.py index 6e4b58fc..818e39c2 100644 --- a/src/primary/apps/radarr.py +++ b/src/primary/apps/radarr.py @@ -4,7 +4,6 @@ from src.primary.utils.logger import get_logger from src.primary.state import get_state_file_path from src.primary.settings_manager import load_settings, get_ssl_verify_setting -from src.primary.apps.radarr.api import arr_request radarr_bp = Blueprint('radarr', __name__) radarr_logger = get_logger("radarr") @@ -24,29 +23,101 @@ def test_connection(): if not api_url or not api_key: return jsonify({"success": False, "message": "API URL and API Key are required"}), 400 + # Log the test attempt radarr_logger.info(f"Testing connection to Radarr API at {api_url}") + # First check if URL is properly formatted if not (api_url.startswith('http://') or api_url.startswith('https://')): error_msg = "API URL must start with http:// or https://" radarr_logger.error(error_msg) return jsonify({"success": False, "message": error_msg}), 400 + + # For Radarr, use api/v3 + api_base = "api/v3" + test_url = f"{api_url.rstrip('/')}/{api_base}/system/status" + headers = {'X-Api-Key': api_key} + # Get SSL verification setting verify_ssl = get_ssl_verify_setting() + if not verify_ssl: radarr_logger.debug("SSL verification disabled by user setting for connection test") try: - response_data = arr_request(api_url, api_key, api_timeout, "system/status", verify_ssl=verify_ssl) - if not response_data: - error_msg = "No response or invalid response from Radarr API" - radarr_logger.error(error_msg) + # Use a connection timeout separate from read timeout + response = requests.get(test_url, headers=headers, timeout=(10, api_timeout), verify=verify_ssl) + + # Log HTTP status code for diagnostic purposes + radarr_logger.debug(f"Radarr API status code: {response.status_code}") + + # Check HTTP status code + response.raise_for_status() + + # Ensure the response is valid JSON + try: + response_data = response.json() + + # We no longer save keys here since we use instances + # keys_manager.save_api_keys("radarr", api_url, api_key) + + radarr_logger.info(f"Successfully connected to Radarr API version: {response_data.get('version', 'unknown')}") + + # Return success with some useful information + return jsonify({ + "success": True, + "message": "Successfully connected to Radarr API", + "version": response_data.get('version', 'unknown') + }) + except ValueError: + error_msg = "Invalid JSON response from Radarr API" + radarr_logger.error(f"{error_msg}. Response content: {response.text[:200]}") return jsonify({"success": False, "message": error_msg}), 500 - radarr_logger.info(f"Successfully connected to Radarr API version: {response_data.get('version', 'unknown')}") - return jsonify({ - "success": True, - "message": "Successfully connected to Radarr API", - "version": response_data.get('version', 'unknown') - }) + + except requests.exceptions.Timeout as e: + error_msg = f"Connection timed out after {api_timeout} seconds" + radarr_logger.error(f"{error_msg}: {str(e)}") + return jsonify({"success": False, "message": error_msg}), 504 + + except requests.exceptions.ConnectionError as e: + error_msg = "Connection error - check hostname and port" + details = str(e) + # Check for common DNS resolution errors + if "Name or service not known" in details or "getaddrinfo failed" in details: + error_msg = "DNS resolution failed - check hostname" + # Check for common connection refused errors + elif "Connection refused" in details: + error_msg = "Connection refused - check if Radarr is running and the port is correct" + + radarr_logger.error(f"{error_msg}: {details}") + return jsonify({"success": False, "message": f"{error_msg}: {details}"}), 502 + + except requests.exceptions.RequestException as e: + error_message = f"Connection failed: {str(e)}" + + if hasattr(e, 'response') and e.response is not None: + status_code = e.response.status_code + + # Add specific messages based on common status codes + if status_code == 401: + error_message = "Authentication failed: Invalid API key" + elif status_code == 403: + error_message = "Access forbidden: Check API key permissions" + elif status_code == 404: + error_message = "API endpoint not found: Check API URL" + elif status_code >= 500: + error_message = f"Radarr server error (HTTP {status_code}): The Radarr server is experiencing issues" + + # Try to extract more error details if available + try: + error_details = e.response.json() + error_message += f" - {error_details.get('message', 'No details')}" + except ValueError: + if e.response.text: + error_message += f" - Response: {e.response.text[:200]}" + + radarr_logger.error(error_message) + return jsonify({"success": False, "message": error_message}), 500 + except Exception as e: error_msg = f"An unexpected error occurred: {str(e)}" radarr_logger.error(error_msg) diff --git a/src/primary/apps/radarr/api.py b/src/primary/apps/radarr/api.py index fbb9e798..859d8261 100644 --- a/src/primary/apps/radarr/api.py +++ b/src/primary/apps/radarr/api.py @@ -20,7 +20,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: """ Make a request to the Radarr API. @@ -31,7 +31,6 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call (without /api/v3/) method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests - verify_ssl: Optional SSL verification flag. (THIS PARAMETER IS NOW IGNORED in favor of global setting) Returns: The parsed JSON response or None if the request failed @@ -53,11 +52,10 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met "User-Agent": "Huntarr/1.0 (https://github.com/plexguide/Huntarr.io)" } - # Get SSL verification setting - ALWAYS use global, ignore verify_ssl parameter + # Get SSL verification setting verify_ssl = get_ssl_verify_setting() if not verify_ssl: - # Updated log message to reflect that the global setting is used. radarr_logger.debug("SSL verification disabled by global user setting") # Make the request based on the method @@ -102,13 +100,14 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int return -1 try: # Radarr uses /api/v3/queue - endpoint = f"{api_url.rstrip('/')}/api/v3/queue?page=1&pageSize=1000" # Fetch a large page size - headers = {"X-Api-Key": api_key} verify_ssl = get_ssl_verify_setting() if not verify_ssl: radarr_logger.debug("SSL verification disabled by user setting for queue size check") - response = session.get(endpoint, headers=headers, timeout=api_timeout, verify=verify_ssl) - response.raise_for_status() + params = { + "page": 1, + "pageSize": 1000 # Fetch a large page size to get all items + } + response = arr_request(api_url, api_key, api_timeout, "queue", params=params) queue_data = response.json() queue_size = queue_data.get('totalRecords', 0) radarr_logger.debug(f"Radarr download queue size: {queue_size}") diff --git a/src/primary/apps/readarr/api.py b/src/primary/apps/readarr/api.py index 864a06f5..5f03436a 100644 --- a/src/primary/apps/readarr/api.py +++ b/src/primary/apps/readarr/api.py @@ -24,7 +24,7 @@ # Default API timeout in seconds - used as fallback only API_TIMEOUT = 30 -def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: """Check the connection to Readarr API.""" try: # Ensure api_url is properly formatted @@ -47,9 +47,8 @@ def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: O "User-Agent": "Huntarr/1.0 (https://github.com/plexguide/Huntarr.io)" } logger.debug(f"Using User-Agent: {headers['User-Agent']}") - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() - response = requests.get(full_url, headers=headers, timeout=api_timeout, verify=verify_ssl) + + response = requests.get(full_url, headers=headers, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) logger.debug("Successfully connected to Readarr.") return True @@ -84,9 +83,9 @@ def get_download_queue_size(api_url: str = None, api_key: str = None, timeout: i "X-Api-Key": api_key, "Content-Type": "application/json" } - + verify_ssl = get_ssl_verify_setting() # Make the request - response = session.get(url, headers=headers, timeout=timeout) + response = session.get(url, headers=headers, timeout=timeout, verify=verify_ssl) response.raise_for_status() # Parse JSON response @@ -106,7 +105,7 @@ def get_download_queue_size(api_url: str = None, api_key: str = None, timeout: i def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: str = "readarr", api_url: str = None, api_key: str = None, api_timeout: int = None, - params: Dict = None, instance_data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: + params: Dict = None, instance_data: Dict = None) -> Any: """ Make a request to the Readarr API. @@ -123,7 +122,6 @@ def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: api_timeout: Optional timeout override params: Optional query parameters instance_data: Optional specific instance data to use - verify_ssl: Optional override for SSL verification Returns: The parsed JSON response or None if the request failed @@ -193,8 +191,7 @@ def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: } # Get SSL verification setting - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() + verify_ssl = get_ssl_verify_setting() if not verify_ssl: logger.debug("SSL verification disabled by user setting") @@ -389,7 +386,7 @@ def get_author_details(api_url: str, api_key: str, author_id: int, api_timeout: endpoint = f"{api_url}/api/v1/author/{author_id}" headers = {'X-Api-Key': api_key} try: - response = requests.get(endpoint, headers=headers, timeout=api_timeout) + response = requests.get(endpoint, headers=headers, timeout=api_timeout, verify=get_ssl_verify_setting()) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) author_data = response.json() logger.debug(f"Successfully fetched details for author ID {author_id}.") diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 1cc8ebca..345d2474 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -463,7 +463,7 @@ def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, moni sonarr_logger.debug(f"Returning {len(all_cutoff_unmet)} cutoff unmet episodes (monitored_only=False).") return all_cutoff_unmet -def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int, series_id: Optional[int] = None) -> List[Dict[str, Any]]: +def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeout: int, monitored_only: bool, count: int) -> List[Dict[str, Any]]: """ Get a specified number of random cutoff unmet episodes by selecting a random page. This is much more efficient for very large libraries. @@ -493,14 +493,6 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in for attempt in range(retries + 1): try: # Get total record count from a minimal query - sonarr_logger.debug(f"Getting missing episodes count (attempt {attempt+1}/{retries+1})") - sonarr_logger.debug(f"Requesting missing episodes count with endpoint: {endpoint} with params {params}") - sonarr_logger.debug(f"Sonarr API URL: {api_url}") - sonarr_logger.debug(f"Sonarr API Timeout: {api_timeout}") - - # Explicitly log the SSL verification setting just before the call - ssl_verify = get_ssl_verify_setting() - sonarr_logger.debug(f"get_missing_episodes_random_page: SSL verify for '{endpoint}' will be: {ssl_verify}") response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) if not response or "totalRecords" not in response: diff --git a/src/primary/apps/whisparr/api.py b/src/primary/apps/whisparr/api.py index e6c02625..dd2e8ad1 100644 --- a/src/primary/apps/whisparr/api.py +++ b/src/primary/apps/whisparr/api.py @@ -22,7 +22,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, verify_ssl: Optional[bool] = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: """ Make a request to the Whisparr API. @@ -33,7 +33,6 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met endpoint: The API endpoint to call method: HTTP method (GET, POST, PUT, DELETE) data: Optional data payload for POST/PUT requests - verify_ssl: Optional override for SSL verification Returns: The parsed JSON response or None if the request failed @@ -61,8 +60,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met } # Get SSL verification setting - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() + verify_ssl = get_ssl_verify_setting() if not verify_ssl: whisparr_logger.debug("SSL verification disabled by user setting") @@ -86,13 +84,13 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met whisparr_logger.debug(f"Standard path returned 404, trying with V3 path: {v3_url}") if method == "GET": - response = session.get(v3_url, headers=headers, timeout=api_timeout) + response = session.get(v3_url, headers=headers, timeout=api_timeout, verify=verify_ssl) elif method == "POST": - response = session.post(v3_url, headers=headers, json=data, timeout=api_timeout) + response = session.post(v3_url, headers=headers, json=data, timeout=api_timeout, verify=verify_ssl) elif method == "PUT": - response = session.put(v3_url, headers=headers, json=data, timeout=api_timeout) + response = session.put(v3_url, headers=headers, json=data, timeout=api_timeout, verify=verify_ssl) elif method == "DELETE": - response = session.delete(v3_url, headers=headers, timeout=api_timeout) + response = session.delete(v3_url, headers=headers, timeout=api_timeout, verify=verify_ssl) whisparr_logger.debug(f"V3 path request returned status code: {response.status_code}") @@ -280,15 +278,18 @@ def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int "X-Api-Key": api_key, "Content-Type": "application/json" } + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: + whisparr_logger.debug("SSL verification disabled by user setting") # Try standard API path first whisparr_logger.debug(f"Attempting command with standard API path: {url}") try: - response = session.post(url, headers=headers, json=payload, timeout=api_timeout) + response = session.post(url, headers=headers, json=payload, timeout=api_timeout, verify=verify_ssl) # If we get a 404 or 405, try the v3 path if response.status_code in [404, 405]: whisparr_logger.debug(f"Standard path returned {response.status_code}, trying with V3 path: {backup_url}") - response = session.post(backup_url, headers=headers, json=payload, timeout=api_timeout) + response = session.post(backup_url, headers=headers, json=payload, timeout=api_timeout, verify=verify_ssl) response.raise_for_status() result = response.json() @@ -339,15 +340,18 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: "X-Api-Key": api_key, "Content-Type": "application/json" } + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: + whisparr_logger.debug("SSL verification disabled by user setting") # Try standard API path first whisparr_logger.debug(f"Checking command status with standard API path: {url}") try: - response = session.get(url, headers=headers, timeout=api_timeout) + response = session.get(url, headers=headers, timeout=api_timeout, verify=verify_ssl) # If we get a 404, try the v3 path if response.status_code == 404: whisparr_logger.debug(f"Standard path returned 404, trying with V3 path: {backup_url}") - response = session.get(backup_url, headers=headers, timeout=api_timeout) + response = session.get(backup_url, headers=headers, timeout=api_timeout, verify=verify_ssl) response.raise_for_status() result = response.json() @@ -366,7 +370,7 @@ def get_command_status(api_url: str, api_key: str, api_timeout: int, command_id: whisparr_logger.error(f"Error getting command status for ID {command_id}: {e}") return None -def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: Optional[bool] = None) -> bool: +def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: """ Check the connection to Whisparr V2 API. @@ -382,6 +386,9 @@ def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: O try: # For Whisparr V2, we need to handle both regular and v3 API formats whisparr_logger.debug(f"Checking connection to Whisparr V2 instance at {api_url}") + verify_ssl = get_ssl_verify_setting() + if not verify_ssl: + whisparr_logger.debug("SSL verification disabled by user setting") # First try with standard path endpoint = "system/status" @@ -391,8 +398,6 @@ def check_connection(api_url: str, api_key: str, api_timeout: int, verify_ssl: O # Try direct HTTP request to v3 endpoint without using arr_request url = f"{api_url.rstrip('/')}/api/v3/system/status" headers = {'X-Api-Key': api_key} - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() try: resp = session.get(url, headers=headers, timeout=api_timeout, verify=verify_ssl) resp.raise_for_status() From f7ecf5faeab7e149a57a7364779db18cde1420f0 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Mon, 26 May 2025 21:16:37 -0600 Subject: [PATCH 23/30] fix: initialize series_id in get_cutoff_unmet_episodes_random_page function and clean up logging --- src/primary/apps/sonarr/api.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 345d2474..7877e267 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -482,7 +482,8 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou page_size = 1000 retries = 2 retry_delay = 3 - + series_id = None + # First, make a request to get just the total record count (page 1 with size=1) params = { "page": 1, @@ -493,7 +494,6 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou for attempt in range(retries + 1): try: # Get total record count from a minimal query - response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) if not response or "totalRecords" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing count (attempt {attempt+1})") @@ -527,10 +527,6 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou if series_id is not None: params["seriesId"] = series_id - # Explicitly log the SSL verification setting just before the call - current_ssl_verify_setting_page = get_ssl_verify_setting() - sonarr_logger.debug(f"SSL verify setting before calling arr_request for page {random_page}: {current_ssl_verify_setting_page}") - response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) if not response or "records" not in response: sonarr_logger.warning(f"Empty or invalid response when getting missing episodes page {random_page}") From 1f91a8af360e01f4c4b3c9e46fbff87196e23904 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Mon, 26 May 2025 21:51:19 -0600 Subject: [PATCH 24/30] fix: return raw response in get_system_status function for better error handling --- src/primary/apps/lidarr/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primary/apps/lidarr/api.py b/src/primary/apps/lidarr/api.py index 4e84ebf0..50ba9981 100644 --- a/src/primary/apps/lidarr/api.py +++ b/src/primary/apps/lidarr/api.py @@ -137,7 +137,7 @@ def get_system_status(api_url: str, api_key: str, api_timeout: int) -> Dict: response = arr_request(api_url, api_key, api_timeout, "system/status", method="GET") # Parse and return the result - return response.json() + return response except Exception as e: lidarr_logger.error(f"Error getting system status: {str(e)}") return {} From c3e3134f06280d30e19516378e93621e411ae12f Mon Sep 17 00:00:00 2001 From: jharder01 Date: Mon, 26 May 2025 21:58:47 -0600 Subject: [PATCH 25/30] fix: update function call to get_cutoff_unmet_episodes_random_page for improved episode selection --- src/primary/apps/sonarr/missing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primary/apps/sonarr/missing.py b/src/primary/apps/sonarr/missing.py index 2cbeb2bb..b82b25c4 100644 --- a/src/primary/apps/sonarr/missing.py +++ b/src/primary/apps/sonarr/missing.py @@ -86,7 +86,7 @@ def process_missing_episodes_mode( # Always use random selection for missing episodes sonarr_logger.info(f"Using random selection for missing episodes") - episodes_to_search = sonarr_api.get_missing_episodes_random_page( + episodes_to_search = sonarr_api.get_cutoff_unmet_episodes_random_page( api_url, api_key, api_timeout, monitored_only, hunt_missing_items) if stop_check(): From 753ef72fffc1123b73c5f3712ff05a797ee281e7 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Mon, 26 May 2025 22:17:53 -0600 Subject: [PATCH 26/30] fix: improve logging in arr_request function for better request tracking --- src/primary/apps/sonarr/api.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 7877e267..65a12f46 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -50,9 +50,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # Construct the full URL properly full_url = f"{api_url.rstrip('/')}/api/v3/{endpoint.lstrip('/')}" - - sonarr_logger.debug(f"Making {method} request to: {full_url}") - + # Set up headers with User-Agent to identify Huntarr headers = { "X-Api-Key": api_key, @@ -65,7 +63,8 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met # Get SSL verification setting verify_ssl = get_ssl_verify_setting() - sonarr_logger.debug(f"arr_request: For URL {full_url}, effective SSL verification for requests call will be: {verify_ssl} (type: {type(verify_ssl)})") + + sonarr_logger.debug(f"Making {method} request to: {full_url} with SSL verification: {verify_ssl}") try: if method.upper() == "GET": From ae37d602eb5cecd79bbce1d65a9005966047a552 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sat, 31 May 2025 21:48:10 -0500 Subject: [PATCH 27/30] fix: correct bug in obtaining queue size --- src/primary/apps/radarr/api.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/primary/apps/radarr/api.py b/src/primary/apps/radarr/api.py index 859d8261..25969846 100644 --- a/src/primary/apps/radarr/api.py +++ b/src/primary/apps/radarr/api.py @@ -20,7 +20,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Optional[Dict] = None, params: Optional[Dict] = None) -> Any: """ Make a request to the Radarr API. @@ -100,15 +100,12 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int return -1 try: # Radarr uses /api/v3/queue - verify_ssl = get_ssl_verify_setting() - if not verify_ssl: - radarr_logger.debug("SSL verification disabled by user setting for queue size check") params = { "page": 1, "pageSize": 1000 # Fetch a large page size to get all items } - response = arr_request(api_url, api_key, api_timeout, "queue", params=params) - queue_data = response.json() + + queue_data = arr_request(api_url, api_key, api_timeout, "queue", params=params) queue_size = queue_data.get('totalRecords', 0) radarr_logger.debug(f"Radarr download queue size: {queue_size}") return queue_size From 08d63ae2428ab8a8bfe89ffaded19f73390bd0a5 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sat, 31 May 2025 21:54:05 -0500 Subject: [PATCH 28/30] fix: resolve merge conflict in arr_request function signature --- src/primary/apps/radarr/api.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/primary/apps/radarr/api.py b/src/primary/apps/radarr/api.py index 35490a7e..f964441a 100644 --- a/src/primary/apps/radarr/api.py +++ b/src/primary/apps/radarr/api.py @@ -20,11 +20,7 @@ # Use a session for better performance session = requests.Session() -<<<<<<< HEAD -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Optional[Dict] = None, params: Optional[Dict] = None) -> Any: -======= -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None, count_api: bool = True) -> Any: ->>>>>>> 846c1e310d492976e406d1c5ce6bb520470a6eef +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Optional[Dict] = None, params: Optional[Dict] = None, count_api: bool = True) -> Any: """ Make a request to the Radarr API. From 7ebf5470f39690137ff8bef0bcba38ecc684c539 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sat, 31 May 2025 22:05:39 -0500 Subject: [PATCH 29/30] fix: update arr_request function to use Optional for data and params, improve get_download_queue_size logic --- src/primary/apps/sonarr/api.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/primary/apps/sonarr/api.py b/src/primary/apps/sonarr/api.py index 65a12f46..542112c4 100644 --- a/src/primary/apps/sonarr/api.py +++ b/src/primary/apps/sonarr/api.py @@ -22,7 +22,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None, params: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Optional[Dict] = None, params: Optional[Dict] = None) -> Any: """ Make a request to the Sonarr API. @@ -620,8 +620,12 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int for attempt in range(retries + 1): try: # Use arr_request to get queue info (page=1, pageSize=1 for just the count) - endpoint = "queue?page=1&pageSize=1&includeSeries=false" - response = arr_request(api_url, api_key, api_timeout, endpoint) + params = { + 'page': '1', + 'pageSize': '1', + 'includeSeries': 'false' + } + response = arr_request(api_url, api_key, api_timeout, "queue", params=params) if not response: sonarr_logger.warning(f"Empty response when getting queue size (attempt {attempt+1}/{retries+1})") if attempt < retries: @@ -630,7 +634,7 @@ def get_download_queue_size(api_url: str, api_key: str, api_timeout: int) -> int return -1 try: - queue_data = response.json() + queue_data = response queue_size = queue_data.get('totalRecords', 0) sonarr_logger.debug(f"Sonarr download queue size: {queue_size}") return queue_size From cd0e3aaac63b6ecec639bc54b8f6889c4ccd32d5 Mon Sep 17 00:00:00 2001 From: jharder01 Date: Sat, 31 May 2025 22:13:52 -0500 Subject: [PATCH 30/30] fix: update arr_request function to use Optional for params, streamline SSL verification logic --- src/primary/apps/eros/api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/primary/apps/eros/api.py b/src/primary/apps/eros/api.py index 427dd523..3eb03941 100644 --- a/src/primary/apps/eros/api.py +++ b/src/primary/apps/eros/api.py @@ -22,7 +22,7 @@ # Use a session for better performance session = requests.Session() -def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Dict = None) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", data: Optional[Dict] = None, params: Optional[Dict] = None) -> Any: """ Make a request to the Eros API. @@ -60,8 +60,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met } # Get SSL verification setting - if verify_ssl is None: - verify_ssl = get_ssl_verify_setting() + verify_ssl = get_ssl_verify_setting() if not verify_ssl: eros_logger.debug("SSL verification disabled by user setting")