diff --git a/src/primary/api.py b/src/primary/api.py index a21888d4..112ca0ac 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,18 @@ 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 + verify_ssl = get_ssl_verify_setting() + + if not verify_ssl: + 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 @@ -115,7 +122,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) + + # Get SSL verification setting + verify_ssl = get_ssl_verify_setting() + + if not verify_ssl: + 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()}") diff --git a/src/primary/apps/eros/api.py b/src/primary/apps/eros/api.py index 39aca4ad..cd554c40 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, count_api: bool = True) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", count_api: bool = True, data: Optional[Dict] = None, params: Optional[Dict] = None) -> Any: """ Make a request to the Eros API. @@ -344,7 +344,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. @@ -389,7 +389,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"] @@ -455,6 +455,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 diff --git a/src/primary/apps/lidarr/api.py b/src/primary/apps/lidarr/api.py index 6a4fa780..c7e6e268 100644 --- a/src/primary/apps/lidarr/api.py +++ b/src/primary/apps/lidarr/api.py @@ -52,28 +52,28 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met clean_endpoint = endpoint.lstrip('/') # Construct full URL with V1 API prefix for Lidarr - full_url = f"{base_url}/api/v1/{clean_endpoint}" - - # Setup headers + full_url = f"{base_url}/api/v1/{clean_endpoint}" + + # Set up headers with User-Agent to identify Huntarr headers = { "X-Api-Key": api_key, - "Content-Type": "application/json" + "Content-Type": "application/json", + "User-Agent": "Huntarr/1.0 (https://github.com/plexguide/Huntarr.io)" } - + + lidarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") + # Get SSL verification setting verify_ssl = get_ssl_verify_setting() - - # Log the request details - lidarr_logger.debug(f"Making {method} request to Lidarr: {full_url}") - if params: - lidarr_logger.debug(f"Request params: {params}") - if data: - debug_log("Lidarr API request payload", data, "lidarr") - - # Make the request - response = requests.request( - method.upper(), - full_url, + + if not verify_ssl: + lidarr_logger.debug("SSL verification disabled by user setting") + + lidarr_logger.debug(f"Lidarr API Request: {method} {full_url} Params: {params} Data: {data}") + + response = session.request( + method=method.upper(), + url=full_url, headers=headers, json=data if data else None, params=params if method.upper() == "GET" else None, @@ -134,7 +134,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. @@ -142,30 +142,19 @@ 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() + return response except Exception as e: lidarr_logger.error(f"Error getting system status: {str(e)}") return {} @@ -182,11 +171,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) - - # Get SSL verification setting - 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')}") @@ -196,8 +182,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/radarr/api.py b/src/primary/apps/radarr/api.py index 101e0a3c..80e26591 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, params: Dict = None, count_api: bool = True) -> 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, count_api: bool = True) -> Any: """ Make a request to the Radarr API. @@ -64,7 +64,7 @@ def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, met verify_ssl = get_ssl_verify_setting() if not verify_ssl: - radarr_logger.debug("SSL verification disabled by user setting") + radarr_logger.debug("SSL verification disabled by global user setting") # Make the request based on the method if method.upper() == "GET": @@ -112,11 +112,12 @@ 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} - response = session.get(endpoint, headers=headers, timeout=api_timeout) - response.raise_for_status() - queue_data = response.json() + params = { + "page": 1, + "pageSize": 1000 # Fetch a large page size to get all items + } + + 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 @@ -171,6 +172,7 @@ def get_cutoff_unmet_movies(api_url: str, api_key: str, api_timeout: int, monito Returns: A list of movie objects that need quality upgrades, or None if the request failed. """ + # Use Radarr's dedicated cutoff endpoint radarr_logger.debug(f"Fetching cutoff unmet movies (monitored_only={monitored_only})...") @@ -344,36 +346,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" - - response = requests.get(full_url, headers={"X-Api-Key": api_key}, timeout=api_timeout) - 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/readarr/api.py b/src/primary/apps/readarr/api.py index 5843c1e5..c6988216 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: "User-Agent": "Huntarr/1.0 (https://github.com/plexguide/Huntarr.io)" } - 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 @@ -83,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 @@ -192,7 +192,6 @@ def arr_request(endpoint: str, method: str = "GET", data: Dict = None, app_type: # Get SSL verification setting verify_ssl = get_ssl_verify_setting() - if not verify_ssl: logger.debug("SSL verification disabled by user setting") @@ -317,7 +316,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() @@ -536,7 +535,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}.") @@ -569,7 +568,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 7e0327ae..cc80c335 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 @@ -21,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, count_api: bool = True) -> Any: +def arr_request(api_url: str, api_key: str, api_timeout: int, endpoint: str, method: str = "GET", count_api: bool = True, data: Optional[Dict] = None, params: Optional[Dict] = None) -> Any: """ Make a request to the Sonarr API. @@ -32,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 + params: Optional query parameters for GET requests Returns: The parsed JSON response or None if the request failed @@ -48,7 +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('/')}" - + # Set up headers with User-Agent to identify Huntarr headers = { "X-Api-Key": api_key, @@ -56,15 +58,17 @@ 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)" } + # Log the User-Agent for debugging + sonarr_logger.debug(f"Using User-Agent: {headers['User-Agent']}") + # Get SSL verification setting verify_ssl = get_ssl_verify_setting() - if not verify_ssl: - sonarr_logger.debug("SSL verification disabled by user setting") + sonarr_logger.debug(f"Making {method} request to: {full_url} with SSL verification: {verify_ssl}") 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": @@ -232,22 +236,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, count_api=False) - if response: - return response - return [] + + response = arr_request(api_url, api_key, api_timeout, endpoint, params=params, count_api=False) + return response if response else [] def command_status(api_url: str, api_key: str, api_timeout: int, command_id: Union[int, str]) -> Dict: """ @@ -295,95 +292,42 @@ 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 = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) - 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(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 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]]: @@ -412,13 +356,12 @@ def get_cutoff_unmet_episodes(api_url: str, api_key: str, api_timeout: int, moni "sortDir": "asc", "monitored": monitored_only } - url = f"{api_url}/api/v3/{endpoint}" 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) - 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 + 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}") + if isinstance(response, dict): if not response.content: sonarr_logger.warning(f"Empty response for cutoff unmet episodes page {page} (attempt {retry_count+1})") @@ -557,10 +500,12 @@ 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.raise_for_status() - data = response.json() - total_records = data.get('totalRecords', 0) + response = arr_request(api_url, api_key, api_timeout, endpoint, params=params) + 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.") @@ -586,10 +531,11 @@ def get_cutoff_unmet_episodes_random_page(api_url: str, api_key: str, api_timeou "monitored": monitored_only } - response = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) - response.raise_for_status() - - data = response.json() + 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 cutoff unmet episodes page {random_page}") + return [] + data = response records = data.get('records', []) sonarr_logger.info(f"Retrieved {len(records)} episodes from page {random_page}") @@ -639,10 +585,11 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in A list of randomly selected missing episodes, up to the requested count """ endpoint = "wanted/missing" - page_size = 100 # Smaller page size for better performance + page_size = 1000 retries = 2 - retry_delay = 3 - + 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, @@ -650,21 +597,23 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in "includeSeries": "true", # Include series info for filtering "monitored": monitored_only } - url = f"{api_url}/api/v3/{endpoint}" 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) - 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, 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: time.sleep(retry_delay) continue return [] + data = response + total_records = data.get('totalRecords', 0) + + if total_records == 0: + sonarr_logger.info("No missing episodes found in Sonarr.") + return [] try: data = response.json() @@ -696,17 +645,17 @@ 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.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', []) + 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}") + if attempt < retries: + time.sleep(retry_delay) + continue + return [] + + records = response.get('records', []) sonarr_logger.info(f"Retrieved {len(records)} missing episodes from page {random_page}") # Apply monitored filter if requested @@ -742,13 +691,45 @@ def get_missing_episodes_random_page(api_url: str, api_key: str, api_timeout: in continue 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 + + 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 [] + 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: @@ -777,16 +758,27 @@ def search_episode(api_url: str, api_key: str, api_timeout: int, episode_ids: Li # Continue with request if cap check fails - safer than skipping 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) - 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}") - + + response = arr_request( + api_url, + api_key, + api_timeout, + "command", + method="POST", + data=payload + ) + 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 + # Increment API counter after successful request try: from src.primary.stats_manager import increment_hourly_cap @@ -799,6 +791,7 @@ def search_episode(api_url: str, api_key: str, api_timeout: int, episode_ids: Li except requests.exceptions.RequestException as e: sonarr_logger.error(f"Error triggering Sonarr search for episode IDs {episode_ids}: {e}") return None + except Exception as e: sonarr_logger.error(f"An unexpected error occurred while triggering Sonarr search: {e}") return None @@ -806,15 +799,19 @@ 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) - 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" + ) + 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 @@ -826,11 +823,14 @@ 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.raise_for_status() - - if not response.content: + # Use arr_request to get queue info (page=1, pageSize=1 for just the count) + 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: time.sleep(retry_delay) @@ -838,7 +838,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 @@ -878,15 +878,19 @@ 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) - 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" + ) + 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 @@ -905,30 +909,26 @@ def search_season(api_url: str, api_key: str, api_timeout: int, series_id: int, # Continue with request if cap check fails - safer than skipping 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) - 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}") - - # CRITICAL FIX: Track the API call in hourly cap counter - # This was missing and causing API counter to be inaccurate for season packs - try: - from src.primary.stats_manager import increment_hourly_cap - increment_hourly_cap("sonarr", 1) - sonarr_logger.debug(f"Incremented Sonarr hourly API cap for season search (series: {series_id}, season: {season_number})") - except Exception as cap_error: - sonarr_logger.error(f"Failed to increment hourly API cap for season search: {cap_error}") - - 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 + ) + 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 @@ -973,11 +973,10 @@ def get_cutoff_unmet_episodes_for_series(api_url: str, api_key: str, api_timeout "seriesId": series_id, # Filter by series ID - this limits results to only this series "monitored": monitored_only } - 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 = requests.get(url, headers={"X-Api-Key": api_key}, params=params, timeout=api_timeout) + 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 @@ -1103,7 +1102,6 @@ def get_series_with_missing_episodes(api_url: str, api_key: str, api_timeout: in # 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: @@ -1124,15 +1122,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) - response.raise_for_status() - - if not response.content: + endpoint = f"episode?seriesId={series_id}" + episodes = arr_request(api_url, api_key, api_timeout, endpoint) + if not episodes: continue - - episodes = response.json() - # Filter to missing episodes missing_episodes = [ e for e in episodes diff --git a/src/primary/apps/swaparr/handler.py b/src/primary/apps/swaparr/handler.py index 0d6b0435..4ce0bfcb 100644 --- a/src/primary/apps/swaparr/handler.py +++ b/src/primary/apps/swaparr/handler.py @@ -20,6 +20,8 @@ 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 from src.primary.utils.database import get_database from src.primary.apps.swaparr.stats_manager import increment_swaparr_stat @@ -289,10 +291,12 @@ 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: SWAPARR_STATS['api_calls_made'] += 1 - 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() @@ -467,9 +471,14 @@ def trigger_search_for_item(app_name, api_url, api_key, item, api_timeout=120): swaparr_logger.warning(f"Search not supported for app: {app_name}") return False + verify_ssl = get_ssl_verify_setting() + + if not verify_ssl: + swaparr_logger.debug("SSL verification disabled by user setting for trigger_search_for_item") + # Execute the search command SWAPARR_STATS['api_calls_made'] += 1 - response = requests.post(search_url, headers=headers, json=payload, timeout=api_timeout) + response = requests.post(search_url, headers=headers, json=payload, timeout=api_timeout, verify=verify_ssl) response.raise_for_status() swaparr_logger.info(f"Successfully triggered search for {item.get('name', 'unknown')} in {app_name}") @@ -494,10 +503,12 @@ 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: SWAPARR_STATS['api_calls_made'] += 1 - 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}") SWAPARR_STATS['downloads_removed'] += 1 diff --git a/src/primary/apps/whisparr/api.py b/src/primary/apps/whisparr/api.py index 63038121..01381f04 100644 --- a/src/primary/apps/whisparr/api.py +++ b/src/primary/apps/whisparr/api.py @@ -61,7 +61,6 @@ 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 not verify_ssl: whisparr_logger.debug("SSL verification disabled by user setting") @@ -85,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}") @@ -303,11 +302,11 @@ def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int # 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() @@ -367,15 +366,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() @@ -402,6 +404,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 @@ -409,20 +412,22 @@ def check_connection(api_url: str, api_key: str, api_timeout: int) -> bool: 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" response = arr_request(api_url, api_key, api_timeout, endpoint, count_api=False) - + # If that failed, try with v3 path format 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} - 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: