diff --git a/frontend/static/js/apps/eros.js b/frontend/static/js/apps/eros.js new file mode 100644 index 00000000..6331a07f --- /dev/null +++ b/frontend/static/js/apps/eros.js @@ -0,0 +1,196 @@ +/** + * Eros.js - Handles Eros settings and interactions in the Huntarr UI + */ + +document.addEventListener('DOMContentLoaded', function() { + // Don't call setupErosForm here, new-main.js will call it when the tab is active + // setupErosForm(); + // setupErosLogs(); // Assuming logs are handled by the main logs section + // setupClearProcessedButtons('eros'); // Assuming this is handled elsewhere or not needed immediately +}); + +/** + * Setup Eros settings form and connection test + * This function is now called by new-main.js when the Eros settings tab is shown. + */ +function setupErosForm() { + console.log("[eros.js] Setting up Eros form..."); + const panel = document.getElementById('erosSettings'); + if (!panel) { + console.warn("[eros.js] Eros settings panel not found."); + return; + } + + const testErosButton = panel.querySelector('#test-eros-button'); + const erosStatusIndicator = panel.querySelector('#eros-connection-status'); + const erosVersionDisplay = panel.querySelector('#eros-version'); + const apiUrlInput = panel.querySelector('#eros_api_url'); + const apiKeyInput = panel.querySelector('#eros_api_key'); + + // Check if event listener is already attached (prevents duplicate handlers) + if (!testErosButton || testErosButton.dataset.listenerAttached === 'true') { + console.log("[eros.js] Test button not found or listener already attached."); + return; + } + console.log("[eros.js] Setting up Eros form listeners."); + testErosButton.dataset.listenerAttached = 'true'; // Mark as attached + + // Add event listener for connection test + testErosButton.addEventListener('click', function() { + console.log("[eros.js] Testing Eros connection..."); + + // Basic validation + if (!apiUrlInput.value || !apiKeyInput.value) { + if (typeof huntarrUI !== 'undefined') { + huntarrUI.showNotification('Please enter both API URL and API Key for Eros', 'error'); + } else { + alert('Please enter both API URL and API Key for Eros'); + } + return; + } + + // Disable button during test and show pending status + testErosButton.disabled = true; + if (erosStatusIndicator) { + erosStatusIndicator.className = 'connection-status pending'; + erosStatusIndicator.textContent = 'Testing...'; + } + + // Call API to test connection + HuntarrUtils.fetchWithTimeout('/api/eros/test-connection', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + api_url: apiUrlInput.value, + api_key: apiKeyInput.value, + api_timeout: 30 + }) + }, 30000) // 30 second timeout + .then(response => response.json()) + .then(data => { + // Enable the button again + testErosButton.disabled = false; + + if (erosStatusIndicator) { + if (data.success) { + erosStatusIndicator.className = 'connection-status success'; + erosStatusIndicator.textContent = 'Connected'; + if (typeof huntarrUI !== 'undefined') { + huntarrUI.showNotification('Successfully connected to Eros', 'success'); + } + getErosVersion(); // Fetch version after successful connection + } else { + erosStatusIndicator.className = 'connection-status failure'; + erosStatusIndicator.textContent = 'Failed'; + if (typeof huntarrUI !== 'undefined') { + huntarrUI.showNotification(data.message || 'Failed to connect to Eros', 'error'); + } else { + alert(data.message || 'Failed to connect to Eros'); + } + } + } + }) + .catch(error => { + console.error('[eros.js] Error testing connection:', error); + testErosButton.disabled = false; + + if (erosStatusIndicator) { + erosStatusIndicator.className = 'connection-status failure'; + erosStatusIndicator.textContent = 'Error'; + } + + if (typeof huntarrUI !== 'undefined') { + huntarrUI.showNotification('Error testing connection: ' + error.message, 'error'); + } else { + alert('Error testing connection: ' + error.message); + } + }); + }); + + // Initialize form state and fetch data + refreshErosStatusAndVersion(); +} + +/** + * Get the Eros software version from the instance. + * This is separate from the API test. + */ +function getErosVersion() { + const panel = document.getElementById('erosSettings'); + if (!panel) return; + + const versionDisplay = panel.querySelector('#eros-version'); + if (!versionDisplay) return; + + // Try to get the API settings from the form + const apiUrlInput = panel.querySelector('#eros_api_url'); + const apiKeyInput = panel.querySelector('#eros_api_key'); + + if (!apiUrlInput || !apiUrlInput.value || !apiKeyInput || !apiKeyInput.value) { + versionDisplay.textContent = 'N/A'; + return; + } + + // Endpoint to get version info - using the test endpoint since it returns version + HuntarrUtils.fetchWithTimeout('/api/eros/test-connection', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + api_url: apiUrlInput.value, + api_key: apiKeyInput.value, + api_timeout: 10 + }) + }, 10000) + .then(response => response.json()) + .then(data => { + if (data.success && data.version) { + versionDisplay.textContent = 'v' + data.version; + } else { + versionDisplay.textContent = 'Unknown'; + } + }) + .catch(error => { + console.error('[eros.js] Error fetching version:', error); + versionDisplay.textContent = 'Error'; + }); +} + +/** + * Refresh the connection status and version display for Eros. + */ +function refreshErosStatusAndVersion() { + // Try to get current connection status from the server + fetch('/api/eros/status') + .then(response => response.json()) + .then(data => { + const panel = document.getElementById('erosSettings'); + if (!panel) return; + + const statusIndicator = panel.querySelector('#eros-connection-status'); + if (statusIndicator) { + if (data.connected) { + statusIndicator.className = 'connection-status success'; + statusIndicator.textContent = 'Connected'; + getErosVersion(); // Try to get version if connected + } else if (data.configured) { + statusIndicator.className = 'connection-status failure'; + statusIndicator.textContent = 'Not Connected'; + } else { + statusIndicator.className = 'connection-status pending'; + statusIndicator.textContent = 'Not Configured'; + } + } + }) + .catch(error => { + console.error('[eros.js] Error checking status:', error); + }); +} + +// Mark functions as global if needed by other parts of the application +window.setupErosForm = setupErosForm; +window.getErosVersion = getErosVersion; +window.refreshErosStatusAndVersion = refreshErosStatusAndVersion; diff --git a/frontend/templates/components/apps_section.html b/frontend/templates/components/apps_section.html index dd716c1a..392f4c70 100644 --- a/frontend/templates/components/apps_section.html +++ b/frontend/templates/components/apps_section.html @@ -11,6 +11,7 @@ Lidarr Readarr Whisparr V2 + Eros Swaparr @@ -25,6 +26,7 @@
+
diff --git a/frontend/templates/components/history_section.html b/frontend/templates/components/history_section.html index e7befbbc..5b44ee05 100644 --- a/frontend/templates/components/history_section.html +++ b/frontend/templates/components/history_section.html @@ -15,6 +15,7 @@ Lidarr Readarr Whisparr + Eros diff --git a/frontend/templates/components/home_section.html b/frontend/templates/components/home_section.html index c4468a82..4f27dc39 100644 --- a/frontend/templates/components/home_section.html +++ b/frontend/templates/components/home_section.html @@ -173,6 +173,29 @@ + + +
+
+ Loading... +
+
+
+ +
+

Eros

+
+
+
+ 0 + Searches Triggered +
+
+ 0 + Upgrades Triggered +
+
+
@@ -416,6 +439,12 @@ box-shadow: 0 0 15px rgba(195, 0, 230, 0.4); } + /* Eros gold/amber icon ring */ + .app-stats-card.eros .app-icon-wrapper { + border: 2px solid rgba(255, 193, 7, 0.8); + box-shadow: 0 0 15px rgba(255, 193, 7, 0.4); + } + .app-logo { width: 40px; height: 40px; @@ -528,6 +557,19 @@ -webkit-text-fill-color: transparent; } + /* App-specific accent colors for eros */ + .app-stats-card.eros .app-icon-wrapper { + border-color: rgba(255, 193, 7, 0.5); + box-shadow: 0 0 15px rgba(255, 193, 7, 0.2); + } + + .app-stats-card.eros .stat-number { + background: linear-gradient(to bottom right, #ffd54f, #ffb300); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + } + /* Status badge colors */ .status-badge.connected { background-color: rgba(39, 174, 96, 0.15); diff --git a/frontend/templates/components/logs_section.html b/frontend/templates/components/logs_section.html index 78b6d670..6c666d46 100644 --- a/frontend/templates/components/logs_section.html +++ b/frontend/templates/components/logs_section.html @@ -15,6 +15,7 @@ Lidarr Readarr Whisparr V2 + Eros Swaparr System diff --git a/src/primary/app_manager.py b/src/primary/app_manager.py index 0901b6a9..7eb80d46 100644 --- a/src/primary/app_manager.py +++ b/src/primary/app_manager.py @@ -7,7 +7,7 @@ from src.primary.settings_manager import load_settings logger = get_logger("app_manager") # List of supported app types -SUPPORTED_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr"] +SUPPORTED_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"] def initialize_apps(): """Initialize all supported applications""" diff --git a/src/primary/apps/blueprints.py b/src/primary/apps/blueprints.py index a3419092..786e29e0 100644 --- a/src/primary/apps/blueprints.py +++ b/src/primary/apps/blueprints.py @@ -11,6 +11,7 @@ from src.primary.apps.lidarr_routes import lidarr_bp from src.primary.apps.readarr_routes import readarr_bp from src.primary.apps.whisparr_routes import whisparr_bp from src.primary.apps.swaparr_routes import swaparr_bp +from src.primary.apps.eros_routes import eros_bp __all__ = [ "sonarr_bp", @@ -18,5 +19,6 @@ __all__ = [ "lidarr_bp", "readarr_bp", "whisparr_bp", - "swaparr_bp" + "swaparr_bp", + "eros_bp" ] \ No newline at end of file diff --git a/src/primary/apps/eros.py b/src/primary/apps/eros.py new file mode 100644 index 00000000..9f81ad35 --- /dev/null +++ b/src/primary/apps/eros.py @@ -0,0 +1,157 @@ +from flask import Blueprint, request, jsonify +import datetime, os, requests +from primary import keys_manager +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 + +eros_bp = Blueprint('eros', __name__) +eros_logger = get_logger("eros") + +# Make sure we're using the correct state files +PROCESSED_MISSING_FILE = get_state_file_path("eros", "processed_missing") +PROCESSED_UPGRADES_FILE = get_state_file_path("eros", "processed_upgrades") + +@eros_bp.route('/test-connection', methods=['POST']) +def test_connection(): + """Test connection to an Eros API instance with comprehensive diagnostics""" + data = request.json + api_url = data.get('api_url') + api_key = data.get('api_key') + api_timeout = data.get('api_timeout', 30) # Use longer timeout for connection test + + 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 + eros_logger.info(f"Testing connection to Eros 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://" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 400 + + # For Eros, we always use /api/v3 path + test_url = f"{api_url.rstrip('/')}/api/v3/system/status" + headers = {'X-Api-Key': api_key} + + try: + # Use a connection timeout separate from read timeout + response = requests.get(test_url, headers=headers, timeout=(10, api_timeout)) + + # Log HTTP status code for diagnostic purposes + eros_logger.debug(f"Eros 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() + eros_logger.debug(f"Eros API response: {response_data}") + + # Verify this is actually an Eros API by checking for version + version = response_data.get('version', None) + if not version: + error_msg = "API response doesn't contain version information, may not be Eros" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 400 + + # The version number should start with 3 for Eros + if version.startswith('3'): + eros_logger.info(f"Successfully connected to Eros API version {version}") + return jsonify({ + "success": True, + "message": f"Successfully connected to Eros (version {version})", + "version": version + }) + elif version.startswith('2'): + error_msg = f"Connected to Whisparr V2 (version {version}). Use the Whisparr integration for V2." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 400 + else: + # Connected to some other version + error_msg = f"Connected to unknown version {version}, but Huntarr requires Eros V3" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 400 + + except ValueError: + error_msg = "Invalid JSON response from API. Are you sure this is an Eros API?" + eros_logger.error(f"{error_msg}. Response: {response.text[:200]}") + return jsonify({"success": False, "message": error_msg}), 400 + + except requests.exceptions.Timeout: + error_msg = f"Connection timed out after {api_timeout} seconds. Check that Eros is running and accessible." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 408 + + except requests.exceptions.ConnectionError: + error_msg = "Failed to connect. Check that the URL is correct and that Eros is running." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 502 + + except requests.exceptions.HTTPError as e: + if response.status_code == 401: + error_msg = "API key invalid or unauthorized" + elif response.status_code == 404: + error_msg = "API endpoint not found. Check that the URL is correct." + else: + error_msg = f"HTTP error: {str(e)}" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), response.status_code + + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 500 + +# Function to check if Eros is configured +def is_configured(): + """Check if Eros API credentials are configured""" + try: + api_keys = keys_manager.load_api_keys("eros") + instances = api_keys.get("instances", []) + + for instance in instances: + if instance.get("enabled", True): + return True + + return False + except Exception as e: + eros_logger.error(f"Error checking if Eros is configured: {str(e)}") + return False + +# Get all valid instances from settings +def get_configured_instances(): + """Get all configured and enabled Eros instances""" + try: + api_keys = keys_manager.load_api_keys("eros") + instances = api_keys.get("instances", []) + + enabled_instances = [] + for instance in instances: + if not instance.get("enabled", True): + continue + + api_url = instance.get("api_url") + api_key = instance.get("api_key") + + if not api_url or not api_key: + continue + + # Add name and timeout + instance_name = instance.get("name", "Default") + api_timeout = instance.get("api_timeout", 90) + + enabled_instances.append({ + "api_url": api_url, + "api_key": api_key, + "instance_name": instance_name, + "api_timeout": api_timeout + }) + + return enabled_instances + except Exception as e: + eros_logger.error(f"Error getting configured Eros instances: {str(e)}") + return [] diff --git a/src/primary/apps/eros/__init__.py b/src/primary/apps/eros/__init__.py new file mode 100644 index 00000000..fb24b00b --- /dev/null +++ b/src/primary/apps/eros/__init__.py @@ -0,0 +1,84 @@ +""" +Eros app module for Huntarr +Contains functionality for missing items and quality upgrades in Eros + +Exclusively supports the v3 API. +""" + +# Module exports +from src.primary.apps.eros.missing import process_missing_items +from src.primary.apps.eros.upgrade import process_cutoff_upgrades +from src.primary.settings_manager import load_settings +from src.primary.utils.logger import get_logger + +# Define logger for this module +eros_logger = get_logger("eros") + +# For backward compatibility +process_missing_scenes = process_missing_items + +def get_configured_instances(): + """Get all configured and enabled Eros instances""" + settings = load_settings("eros") + instances = [] + eros_logger.info(f"Loaded Eros settings for instance check: {settings}") + + if not settings: + eros_logger.debug("No settings found for Eros") + return instances + + # Always use Eros V3 API + eros_logger.info("Using Eros API v3 exclusively") + + # Check if instances are configured + if "instances" in settings and isinstance(settings["instances"], list) and settings["instances"]: + eros_logger.info(f"Found 'instances' list with {len(settings['instances'])} items. Processing...") + for idx, instance in enumerate(settings["instances"]): + eros_logger.debug(f"Checking instance #{idx}: {instance}") + # Enhanced validation + api_url = instance.get("api_url", "").strip() + api_key = instance.get("api_key", "").strip() + + # Enhanced URL validation - ensure URL has proper scheme + if api_url and not (api_url.startswith('http://') or api_url.startswith('https://')): + eros_logger.warning(f"Instance '{instance.get('name', 'Unnamed')}' has URL without http(s) scheme: {api_url}") + api_url = f"http://{api_url}" + eros_logger.warning(f"Auto-correcting URL to: {api_url}") + + is_enabled = instance.get("enabled", True) + + # Only include properly configured instances + if is_enabled and api_url and api_key: + instance_name = instance.get("name", "Default") + + # Create a settings object for this instance by combining global settings with instance-specific ones + instance_settings = settings.copy() + + # Remove instances list to avoid confusion + if "instances" in instance_settings: + del instance_settings["instances"] + + # Override with instance-specific settings + instance_settings["api_url"] = api_url + instance_settings["api_key"] = api_key + instance_settings["instance_name"] = instance_name + + # Add timeout setting with default if not present + if "api_timeout" not in instance_settings: + instance_settings["api_timeout"] = 30 + + eros_logger.info(f"Adding configured Eros instance: {instance_name}") + instances.append(instance_settings) + else: + name = instance.get("name", "Unnamed") + if not is_enabled: + eros_logger.debug(f"Skipping disabled instance: {name}") + else: + eros_logger.warning(f"Skipping instance {name} due to missing API URL or API Key") + else: + eros_logger.debug("No instances array found in settings or it's empty") + + eros_logger.info(f"Found {len(instances)} configured and enabled Eros instances") + return instances + +__all__ = ["process_missing_items", "process_missing_scenes", "process_cutoff_upgrades", "get_configured_instances"] diff --git a/src/primary/apps/eros/api.py b/src/primary/apps/eros/api.py new file mode 100644 index 00000000..bd0d9901 --- /dev/null +++ b/src/primary/apps/eros/api.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +""" +Eros-specific API functions +Handles all communication with the Eros API + +Exclusively uses the Eros API v3 +""" + +import requests +import json +import time +import datetime +import traceback +import sys +from typing import List, Dict, Any, Optional, Union +from src.primary.utils.logger import get_logger + +# Get logger for the Eros app +eros_logger = get_logger("eros") + +# 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: + """ + Make a request to the Eros API. + + Args: + api_url: The base URL of the Eros API + api_key: The API key for authentication + api_timeout: Timeout for the API request + endpoint: The API endpoint to call + method: HTTP method (GET, POST, PUT, DELETE) + data: Optional data to send with the request + + Returns: + The JSON response from the API, or None if the request failed + """ + if not api_url or not api_key: + eros_logger.error("API URL or API key is missing. Check your settings.") + return None + + # Always use v3 API path + api_base = "api/v3" + eros_logger.debug(f"Using Eros API path: {api_base}") + + # Full URL - ensure no double slashes + url = f"{api_url.rstrip('/')}/{api_base}/{endpoint.lstrip('/')}" + + # Add debug logging for the exact URL being called + eros_logger.debug(f"Making {method} request to: {url}") + + # Headers + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + try: + if method == "GET": + response = session.get(url, headers=headers, timeout=api_timeout) + elif method == "POST": + response = session.post(url, headers=headers, json=data, timeout=api_timeout) + elif method == "PUT": + response = session.put(url, headers=headers, json=data, timeout=api_timeout) + elif method == "DELETE": + response = session.delete(url, headers=headers, timeout=api_timeout) + else: + eros_logger.error(f"Unsupported HTTP method: {method}") + return None + + # Check if the request was successful + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + eros_logger.error(f"Error during {method} request to {endpoint}: {e}, Status Code: {response.status_code}") + eros_logger.debug(f"Response content: {response.text[:200]}") + return None + + # Try to parse JSON response + try: + if response.text: + result = response.json() + eros_logger.debug(f"Response from {response.url}: Status {response.status_code}, JSON parsed successfully") + return result + else: + eros_logger.debug(f"Response from {response.url}: Status {response.status_code}, Empty response") + return {} + except json.JSONDecodeError: + eros_logger.error(f"Invalid JSON response from API: {response.text[:200]}") + return None + + except requests.exceptions.RequestException as e: + eros_logger.error(f"Request failed: {e}") + return None + except Exception as e: + 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: + """ + Get the current size of the download queue. + + Args: + api_url: The base URL of the Eros API + api_key: The API key for authentication + api_timeout: Timeout for the API request + + 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") + + if response is None: + return -1 + + # V3 API returns a list directly + if isinstance(response, list): + return len(response) + # Fallback to records format if needed + elif isinstance(response, dict) and "records" in response: + return len(response["records"]) + else: + return -1 + +def get_items_with_missing(api_url: str, api_key: str, api_timeout: int, monitored_only: bool) -> List[Dict[str, Any]]: + """ + Get a list of items with missing files (not downloaded/available). + + Args: + api_url: The base URL of the Eros API + api_key: The API key for authentication + api_timeout: Timeout for the API request + monitored_only: If True, only return monitored items. + + Returns: + A list of item objects with missing files, or None if the request failed. + """ + try: + eros_logger.debug(f"Retrieving missing items...") + + # Endpoint parameters + endpoint = "wanted/missing?pageSize=1000&sortKey=airDateUtc&sortDirection=descending" + + response = arr_request(api_url, api_key, api_timeout, endpoint) + + if response is None: + return None + + # Extract the episodes/items + items = [] + if isinstance(response, dict) and "records" in response: + items = response["records"] + elif isinstance(response, list): + items = response + + # Filter monitored if needed + if monitored_only: + items = [item for item in items if item.get("monitored", False)] + + eros_logger.debug(f"Found {len(items)} missing items") + return items + + except Exception as e: + 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]]: + """ + Get a list of items that don't meet their quality profile cutoff. + + Args: + api_url: The base URL of the Eros API + api_key: The API key for authentication + api_timeout: Timeout for the API request + monitored_only: If True, only return monitored items. + + Returns: + A list of item objects that need quality upgrades, or None if the request failed. + """ + try: + eros_logger.debug(f"Retrieving cutoff unmet items...") + + # Endpoint + endpoint = "wanted/cutoff?pageSize=1000&sortKey=airDateUtc&sortDirection=descending" + + response = arr_request(api_url, api_key, api_timeout, endpoint) + + if response is None: + return None + + # Extract the episodes/items + items = [] + if isinstance(response, dict) and "records" in response: + items = response["records"] + elif isinstance(response, list): + items = response + + eros_logger.debug(f"Found {len(items)} cutoff unmet items") + + # Just filter monitored if needed + if monitored_only: + items = [item for item in items if item.get("monitored", False)] + eros_logger.debug(f"Found {len(items)} cutoff unmet items after filtering monitored") + + return items + + except Exception as e: + eros_logger.error(f"Error retrieving cutoff unmet items: {str(e)}") + return None + +def refresh_item(api_url: str, api_key: str, api_timeout: int, item_id: int) -> int: + """ + Refresh an item in Eros. + + Args: + api_url: The base URL of the Eros API + api_key: The API key for authentication + api_timeout: Timeout for the API request + item_id: The ID of the item to refresh + + Returns: + The command ID if the refresh was triggered successfully, None otherwise + """ + try: + eros_logger.debug(f"Refreshing item with ID {item_id}") + + # Get episode details to find the series ID + episode_endpoint = f"episode/{item_id}" + episode_data = arr_request(api_url, api_key, api_timeout, episode_endpoint) + + if episode_data and "seriesId" in episode_data: + # We have the series ID, use series refresh which is more reliable + series_id = episode_data["seriesId"] + eros_logger.debug(f"Retrieved series ID {series_id} for episode {item_id}, using series refresh") + + # RefreshSeries is generally more reliable + payload = { + "name": "RefreshSeries", + "seriesId": series_id + } + else: + # Fall back to episode refresh if we can't get the series ID + eros_logger.debug(f"Could not retrieve series ID for episode {item_id}, using episode refresh") + payload = { + "name": "RefreshEpisode", + "episodeIds": [item_id] + } + + # Command endpoint + command_endpoint = "command" + + # Make the API request + response = arr_request(api_url, api_key, api_timeout, command_endpoint, "POST", payload) + + if response and "id" in response: + command_id = response["id"] + eros_logger.debug(f"Refresh command triggered with ID {command_id}") + return command_id + else: + eros_logger.error("Failed to trigger refresh command - no command ID returned") + return None + + except Exception as e: + eros_logger.error(f"Error refreshing item: {str(e)}") + return None + +def item_search(api_url: str, api_key: str, api_timeout: int, item_ids: List[int]) -> int: + """ + Trigger a search for one or more items. + + Args: + api_url: The base URL of the Eros API + api_key: The API key for authentication + api_timeout: Timeout for the API request + item_ids: A list of item IDs to search for + + Returns: + The command ID if the search command was triggered successfully, None otherwise + """ + try: + eros_logger.debug(f"Searching for items with IDs: {item_ids}") + + payload = { + "name": "EpisodeSearch", + "episodeIds": item_ids + } + + # Command endpoint + command_endpoint = "command" + + # Make the API request + response = arr_request(api_url, api_key, api_timeout, command_endpoint, "POST", payload) + + if response and "id" in response: + command_id = response["id"] + eros_logger.debug(f"Search command triggered with ID {command_id}") + return command_id + else: + eros_logger.error("Failed to trigger search command - no command ID returned") + return None + + except Exception as e: + eros_logger.error(f"Error searching for items: {str(e)}") + return None + +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. + + Args: + api_url: The base URL of the Eros API + api_key: The API key for authentication + api_timeout: Timeout for the API request + command_id: The ID of the command to check + + Returns: + A dictionary containing the command status, or None if the request failed. + """ + if not command_id: + eros_logger.error("No command ID provided for status check.") + return None + + try: + command_endpoint = f"command/{command_id}" + + # Make the API request + 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')}") + return result + else: + eros_logger.error(f"Failed to get command status for ID {command_id}") + return None + + except Exception as e: + 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: + """ + Check the connection to Eros API. + + Args: + api_url: The base URL of the Eros API + api_key: The API key for authentication + api_timeout: Timeout for the API request + + Returns: + True if the connection is successful, False otherwise + """ + try: + eros_logger.debug(f"Checking connection to Eros instance at {api_url}") + + endpoint = "system/status" + response = arr_request(api_url, api_key, api_timeout, endpoint) + + if response is not None: + # Get the version information if available + version = response.get("version", "unknown") + + # Check if this is a v3.x version + if version and version.startswith('3'): + eros_logger.info(f"Successfully connected to Eros API version: {version}") + return True + else: + eros_logger.warning(f"Connected to server but found unexpected version: {version}, expected 3.x") + return False + else: + eros_logger.error("Failed to connect to Eros API") + return False + + except Exception as e: + eros_logger.error(f"Error checking connection to Eros API: {str(e)}") + return False diff --git a/src/primary/apps/eros/missing.py b/src/primary/apps/eros/missing.py new file mode 100644 index 00000000..af15cb4b --- /dev/null +++ b/src/primary/apps/eros/missing.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +""" +Missing Items Processing for Eros +Handles searching for missing items in Eros + +Exclusively supports the v3 API. +""" + +import time +import random +import datetime +from typing import List, Dict, Any, Set, Callable +from src.primary.utils.logger import get_logger +from src.primary.apps.eros import api as eros_api +from src.primary.stats_manager import increment_stat +from src.primary.stateful_manager import is_processed, add_processed_id +from src.primary.utils.history_utils import log_processed_media +from src.primary.settings_manager import get_advanced_setting +from src.primary.state import check_state_reset + +# Get logger for the app +eros_logger = get_logger("eros") + +def process_missing_items( + app_settings: Dict[str, Any], + stop_check: Callable[[], bool] # Function to check if stop is requested +) -> bool: + """ + Process missing items in Eros based on provided settings. + + Args: + app_settings: Dictionary containing all settings for Eros + stop_check: A function that returns True if the process should stop + + Returns: + True if any items were processed, False otherwise. + """ + eros_logger.info("Starting missing items processing cycle for Eros.") + processed_any = False + + # Reset state files if enough time has passed + check_state_reset("eros") + + # Extract necessary settings + api_url = app_settings.get("api_url") + api_key = app_settings.get("api_key") + instance_name = app_settings.get("instance_name", "Eros Default") + api_timeout = app_settings.get("api_timeout", 90) # Default timeout + monitored_only = app_settings.get("monitored_only", True) + skip_future_releases = app_settings.get("skip_future_releases", True) + skip_item_refresh = app_settings.get("skip_item_refresh", False) + + # Use the new hunt_missing_items parameter name, falling back to hunt_missing_scenes for backwards compatibility + hunt_missing_items = app_settings.get("hunt_missing_items", app_settings.get("hunt_missing_scenes", 0)) + + command_wait_delay = app_settings.get("command_wait_delay", 5) + command_wait_attempts = app_settings.get("command_wait_attempts", 12) + + # Use the centralized advanced setting for stateful management hours + stateful_management_hours = get_advanced_setting("stateful_management_hours", 168) + + # Log that we're using Eros v3 API + eros_logger.info(f"Using Eros API v3 for instance: {instance_name}") + + # Skip if hunt_missing_items is set to a negative value or 0 + if hunt_missing_items <= 0: + eros_logger.info("'hunt_missing_items' setting is 0 or less. Skipping missing item processing.") + return False + + # Check for stop signal + if stop_check(): + eros_logger.info("Stop requested before starting missing items. Aborting...") + return False + + # Get missing items + eros_logger.info(f"Retrieving items with missing files...") + missing_items = eros_api.get_items_with_missing(api_url, api_key, api_timeout, monitored_only) + + if missing_items is None: # API call failed + eros_logger.error("Failed to retrieve missing items from Eros API.") + return False + + if not missing_items: + eros_logger.info("No missing items found.") + return False + + # Check for stop signal after retrieving items + if stop_check(): + eros_logger.info("Stop requested after retrieving missing items. Aborting...") + return False + + eros_logger.info(f"Found {len(missing_items)} items with missing files.") + + # Filter out future releases if configured + if skip_future_releases: + now = datetime.datetime.now(datetime.timezone.utc) + original_count = len(missing_items) + # Eros item object has 'airDateUtc' for release dates + missing_items = [ + item for item in missing_items + if not item.get('airDateUtc') or ( + item.get('airDateUtc') and + datetime.datetime.fromisoformat(item['airDateUtc'].replace('Z', '+00:00')) < now + ) + ] + skipped_count = original_count - len(missing_items) + if skipped_count > 0: + eros_logger.info(f"Skipped {skipped_count} future item releases based on air date.") + + if not missing_items: + eros_logger.info("No missing items left to process after filtering future releases.") + return False + + # Filter out already processed items using stateful management + unprocessed_items = [] + for item in missing_items: + item_id = str(item.get("id")) + if not is_processed("eros", instance_name, item_id): + unprocessed_items.append(item) + else: + eros_logger.debug(f"Skipping already processed item ID: {item_id}") + + eros_logger.info(f"Found {len(unprocessed_items)} unprocessed items out of {len(missing_items)} total items with missing files.") + + if not unprocessed_items: + eros_logger.info(f"No unprocessed items found for {instance_name}. All available items have been processed.") + return False + + items_processed = 0 + processing_done = False + + # Select items to search based on configuration + eros_logger.info(f"Randomly selecting up to {hunt_missing_items} missing items.") + items_to_search = random.sample(unprocessed_items, min(len(unprocessed_items), hunt_missing_items)) + + eros_logger.info(f"Selected {len(items_to_search)} missing items to search.") + + # Process selected items + for item in items_to_search: + # Check for stop signal before each item + if stop_check(): + eros_logger.info("Stop requested during item processing. Aborting...") + break + + # Re-check limit in case it changed + current_limit = app_settings.get("hunt_missing_items", app_settings.get("hunt_missing_scenes", 1)) + if items_processed >= current_limit: + eros_logger.info(f"Reached HUNT_MISSING_ITEMS limit ({current_limit}) for this cycle.") + break + + item_id = item.get("id") + title = item.get("title", "Unknown Title") + season_episode = f"S{item.get('seasonNumber', 0):02d}E{item.get('episodeNumber', 0):02d}" + + eros_logger.info(f"Processing missing item: \"{title}\" - {season_episode} (Item ID: {item_id})") + + # Mark the item as processed BEFORE triggering any searches + add_processed_id("eros", instance_name, str(item_id)) + eros_logger.debug(f"Added item ID {item_id} to processed list for {instance_name}") + + # Refresh the item information if not skipped + refresh_command_id = None + if not skip_item_refresh: + eros_logger.info(" - Refreshing item information...") + refresh_command_id = eros_api.refresh_item(api_url, api_key, api_timeout, item_id) + if refresh_command_id: + eros_logger.info(f"Triggered refresh command {refresh_command_id}. Waiting a few seconds...") + time.sleep(5) # Basic wait + else: + eros_logger.warning(f"Failed to trigger refresh command for item ID: {item_id}. Proceeding without refresh.") + else: + eros_logger.info(" - Skipping item refresh (skip_item_refresh=true)") + + # Check for stop signal before searching + if stop_check(): + eros_logger.info(f"Stop requested before searching for {title}. Aborting...") + break + + # Search for the item + eros_logger.info(" - Searching for missing item...") + search_command_id = eros_api.item_search(api_url, api_key, api_timeout, [item_id]) + if search_command_id: + eros_logger.info(f"Triggered search command {search_command_id}. Assuming success for now.") + + # Log to history system + media_name = f"{title} - {season_episode}" + log_processed_media("eros", media_name, item_id, instance_name, "missing") + eros_logger.debug(f"Logged history entry for item: {media_name}") + + items_processed += 1 + processing_done = True + + # Increment the hunted statistics for Eros + increment_stat("eros", "hunted", 1) + eros_logger.debug(f"Incremented eros hunted statistics by 1") + + # Log progress + current_limit = app_settings.get("hunt_missing_items", app_settings.get("hunt_missing_scenes", 1)) + eros_logger.info(f"Processed {items_processed}/{current_limit} missing items this cycle.") + else: + eros_logger.warning(f"Failed to trigger search command for item ID {item_id}.") + # Do not mark as processed if search couldn't be triggered + continue + + # Log final status + if items_processed > 0: + eros_logger.info(f"Completed processing {items_processed} missing items for this cycle.") + else: + eros_logger.info("No new missing items were processed in this run.") + + return processing_done + +# For backward compatibility with the background processing system +def process_missing_scenes(app_settings, stop_check): + """ + Backwards compatibility function that calls process_missing_items. + + Args: + app_settings: Dictionary containing all settings for Eros + stop_check: A function that returns True if the process should stop + + Returns: + Result from process_missing_items + """ + return process_missing_items(app_settings, stop_check) diff --git a/src/primary/apps/eros/upgrade.py b/src/primary/apps/eros/upgrade.py new file mode 100644 index 00000000..b4afd46f --- /dev/null +++ b/src/primary/apps/eros/upgrade.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Quality Upgrade Processing for Eros +Handles searching for items that need quality upgrades in Eros + +Exclusively supports the v3 API. +""" + +import time +import random +import datetime +from typing import List, Dict, Any, Set, Callable +from src.primary.utils.logger import get_logger +from src.primary.apps.eros import api as eros_api +from src.primary.stats_manager import increment_stat +from src.primary.stateful_manager import is_processed, add_processed_id +from src.primary.utils.history_utils import log_processed_media +from src.primary.settings_manager import get_advanced_setting +from src.primary.state import check_state_reset + +# Get logger for the app +eros_logger = get_logger("eros") + +def process_cutoff_upgrades( + app_settings: Dict[str, Any], + stop_check: Callable[[], bool] # Function to check if stop is requested +) -> bool: + """ + Process quality cutoff upgrades for Eros based on settings. + + Args: + app_settings: Dictionary containing all settings for Eros + stop_check: A function that returns True if the process should stop + + Returns: + True if any items were processed for upgrades, False otherwise. + """ + eros_logger.info("Starting quality cutoff upgrades processing cycle for Eros.") + processed_any = False + + # Reset state files if enough time has passed + check_state_reset("eros") + + # Extract necessary settings + api_url = app_settings.get("api_url") + api_key = app_settings.get("api_key") + instance_name = app_settings.get("instance_name", "Eros Default") + api_timeout = app_settings.get("api_timeout", 90) # Default timeout + monitored_only = app_settings.get("monitored_only", True) + skip_item_refresh = app_settings.get("skip_item_refresh", False) + + # Use the new hunt_upgrade_items parameter name, falling back to hunt_upgrade_scenes for backwards compatibility + hunt_upgrade_items = app_settings.get("hunt_upgrade_items", app_settings.get("hunt_upgrade_scenes", 0)) + + command_wait_delay = app_settings.get("command_wait_delay", 5) + command_wait_attempts = app_settings.get("command_wait_attempts", 12) + state_reset_interval_hours = get_advanced_setting("stateful_management_hours", 168) + + # Log that we're using Eros API v3 + eros_logger.info(f"Using Eros API v3 for instance: {instance_name}") + + # Skip if hunt_upgrade_items is set to 0 + if hunt_upgrade_items <= 0: + eros_logger.info("'hunt_upgrade_items' setting is 0 or less. Skipping quality upgrade processing.") + return False + + # Check for stop signal + if stop_check(): + eros_logger.info("Stop requested before starting quality upgrades. Aborting...") + return False + + # Get items eligible for upgrade + eros_logger.info(f"Retrieving items eligible for cutoff upgrade...") + upgrade_eligible_data = eros_api.get_cutoff_unmet_items(api_url, api_key, api_timeout, monitored_only) + + if not upgrade_eligible_data: + eros_logger.info("No items found eligible for upgrade or error retrieving them.") + return False + + # Check for stop signal after retrieving eligible items + if stop_check(): + eros_logger.info("Stop requested after retrieving upgrade eligible items. Aborting...") + return False + + eros_logger.info(f"Found {len(upgrade_eligible_data)} items eligible for quality upgrade.") + + # Filter out already processed items using stateful management + unprocessed_items = [] + for item in upgrade_eligible_data: + item_id = str(item.get("id")) + if not is_processed("eros", instance_name, item_id): + unprocessed_items.append(item) + else: + eros_logger.debug(f"Skipping already processed item ID: {item_id}") + + eros_logger.info(f"Found {len(unprocessed_items)} unprocessed items out of {len(upgrade_eligible_data)} total items eligible for quality upgrade.") + + if not unprocessed_items: + eros_logger.info(f"No unprocessed items found for {instance_name}. All available items have been processed.") + return False + + items_processed = 0 + processing_done = False + + # Always use random selection for upgrades + eros_logger.info(f"Randomly selecting up to {hunt_upgrade_items} items for quality upgrade.") + items_to_upgrade = random.sample(unprocessed_items, min(len(unprocessed_items), hunt_upgrade_items)) + + eros_logger.info(f"Selected {len(items_to_upgrade)} items for quality upgrade.") + + # Process selected items + for item in items_to_upgrade: + # Check for stop signal before each item + if stop_check(): + eros_logger.info("Stop requested during item processing. Aborting...") + break + + # Re-check limit in case it changed + current_limit = app_settings.get("hunt_upgrade_items", app_settings.get("hunt_upgrade_scenes", 1)) + if items_processed >= current_limit: + eros_logger.info(f"Reached HUNT_UPGRADE_ITEMS limit ({current_limit}) for this cycle.") + break + + item_id = item.get("id") + title = item.get("title", "Unknown Title") + season_episode = f"S{item.get('seasonNumber', 0):02d}E{item.get('episodeNumber', 0):02d}" + + current_quality = item.get("episodeFile", {}).get("quality", {}).get("quality", {}).get("name", "Unknown") + + eros_logger.info(f"Processing item for quality upgrade: \"{title}\" - {season_episode} (Item ID: {item_id})") + eros_logger.info(f" - Current quality: {current_quality}") + + # Mark the item as processed BEFORE triggering any searches + add_processed_id("eros", instance_name, str(item_id)) + eros_logger.debug(f"Added item ID {item_id} to processed list for {instance_name}") + + # Refresh the item information if not skipped + refresh_command_id = None + if not skip_item_refresh: + eros_logger.info(" - Refreshing item information...") + refresh_command_id = eros_api.refresh_item(api_url, api_key, api_timeout, item_id) + if refresh_command_id: + eros_logger.info(f"Triggered refresh command {refresh_command_id}. Waiting a few seconds...") + time.sleep(5) # Basic wait + else: + eros_logger.warning(f"Failed to trigger refresh command for item ID: {item_id}. Proceeding without refresh.") + else: + eros_logger.info(" - Skipping item refresh (skip_item_refresh=true)") + + # Check for stop signal before searching + if stop_check(): + eros_logger.info(f"Stop requested before searching for {title}. Aborting...") + break + + # Search for the item + eros_logger.info(" - Searching for quality upgrade...") + search_command_id = eros_api.item_search(api_url, api_key, api_timeout, [item_id]) + if search_command_id: + eros_logger.info(f"Triggered search command {search_command_id}. Assuming success for now.") + + # Log to history so the upgrade appears in the history UI + series_title = item.get("series", {}).get("title", "Unknown Series") + media_name = f"{series_title} - {season_episode} - {title}" + log_processed_media("eros", media_name, item_id, instance_name, "upgrade") + eros_logger.debug(f"Logged quality upgrade to history for item ID {item_id}") + + items_processed += 1 + processing_done = True + + # Increment the upgraded statistics for Eros + increment_stat("eros", "upgraded", 1) + eros_logger.debug(f"Incremented eros upgraded statistics by 1") + + # Log progress + current_limit = app_settings.get("hunt_upgrade_items", app_settings.get("hunt_upgrade_scenes", 1)) + eros_logger.info(f"Processed {items_processed}/{current_limit} items for quality upgrade this cycle.") + else: + eros_logger.warning(f"Failed to trigger search command for item ID {item_id}.") + # Do not mark as processed if search couldn't be triggered + continue + + # Log final status + if items_processed > 0: + eros_logger.info(f"Completed processing {items_processed} items for quality upgrade for this cycle.") + else: + eros_logger.info("No new items were processed for quality upgrade in this run.") + + return processing_done diff --git a/src/primary/apps/eros_routes.py b/src/primary/apps/eros_routes.py new file mode 100644 index 00000000..40f9485c --- /dev/null +++ b/src/primary/apps/eros_routes.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 + +from flask import Blueprint, request, jsonify +import datetime, os, requests +from src.primary import keys_manager +from src.primary.state import get_state_file_path, reset_state_file +from src.primary.utils.logger import get_logger, APP_LOG_FILES +import traceback +import socket +from urllib.parse import urlparse +from src.primary.apps.eros import api as eros_api + +eros_bp = Blueprint('eros', __name__) +eros_logger = get_logger("eros") + +# Make sure we're using the correct state files +PROCESSED_MISSING_FILE = get_state_file_path("eros", "processed_missing") +PROCESSED_UPGRADES_FILE = get_state_file_path("eros", "processed_upgrades") + +@eros_bp.route('/status', methods=['GET']) +def get_status(): + """Get the status of all configured Eros instances""" + try: + # Get all configured instances + api_keys = keys_manager.load_api_keys("eros") + instances = api_keys.get("instances", []) + + connected_count = 0 + total_configured = len(instances) + + for instance in instances: + api_url = instance.get("api_url") + api_key = instance.get("api_key") + if api_url and api_key and instance.get("enabled", True): + # Use a short timeout for status checks + if eros_api.check_connection(api_url, api_key, 5): + connected_count += 1 + + return jsonify({ + "configured": total_configured > 0, + "connected": connected_count > 0, + "connected_count": connected_count, + "total_configured": total_configured + }) + except Exception as e: + eros_logger.error(f"Error getting Eros status: {str(e)}") + return jsonify({ + "configured": False, + "connected": False, + "error": str(e) + }), 500 + +@eros_bp.route('/test-connection', methods=['POST']) +def test_connection(): + """Test connection to an Eros API instance""" + data = request.json + api_url = data.get('api_url') + api_key = data.get('api_key') + api_timeout = data.get('api_timeout', 30) # Use longer timeout for connection test + + if not api_url or not api_key: + return jsonify({"success": False, "message": "API URL and API Key are required"}), 400 + + eros_logger.info(f"Testing connection to Eros API at {api_url}") + + # Validate URL format + if not (api_url.startswith('http://') or api_url.startswith('https://')): + error_msg = "API URL must start with http:// or https://" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 400 + + # Try to establish a socket connection first to check basic connectivity + parsed_url = urlparse(api_url) + hostname = parsed_url.hostname + port = parsed_url.port or (443 if parsed_url.scheme == 'https' else 80) + + try: + # Try socket connection for quick feedback on connectivity issues + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) # Short timeout for quick feedback + result = sock.connect_ex((hostname, port)) + sock.close() + + if result != 0: + error_msg = f"Connection refused - Unable to connect to {hostname}:{port}. Please check if the server is running and the port is correct." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 404 + except socket.gaierror: + error_msg = f"DNS resolution failed - Cannot resolve hostname: {hostname}. Please check your URL." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 404 + except Exception as e: + # Log the socket testing error but continue with the full request + eros_logger.debug(f"Socket test error, continuing with full request: {str(e)}") + + # For Eros, we only use v3 API path + api_url = f"{api_url.rstrip('/')}/api/v3/system/status" + headers = {'X-Api-Key': api_key} + + try: + # Make the request with appropriate timeouts + eros_logger.debug(f"Trying API path: {api_url}") + response = requests.get(api_url, headers=headers, timeout=(5, api_timeout)) + + try: + response.raise_for_status() + + # Check if we got a valid JSON response + try: + response_data = response.json() + + # Verify this is actually an Eros server by checking for version + version = response_data.get('version') + if not version: + error_msg = "API response doesn't contain version information. This doesn't appear to be a valid Eros server." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 400 + + # Version check - should be v3.x for Eros + if version.startswith('3'): + detected_version = "v3" + eros_logger.info(f"Successfully connected to Eros API version: {version} (API {detected_version})") + + # Success! + return jsonify({ + "success": True, + "message": "Successfully connected to Eros API", + "version": version, + "api_version": detected_version + }) + elif version.startswith('2'): + error_msg = f"Incompatible version detected: {version}. This appears to be Whisparr V2, not Eros." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 400 + else: + error_msg = f"Unexpected version {version} detected. Eros requires API v3." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 400 + except ValueError: + error_msg = "Invalid JSON response from Eros API - This doesn't appear to be a valid Eros server" + eros_logger.error(f"{error_msg}. Response content: {response.text[:200]}") + return jsonify({"success": False, "message": error_msg}), 400 + + except requests.exceptions.HTTPError: + # Handle specific HTTP errors + if response.status_code == 401: + error_msg = "Invalid API key - Authentication failed" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 401 + elif response.status_code == 404: + error_msg = "API endpoint not found: This doesn't appear to be a valid Eros server. Check your URL." + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 404 + else: + error_msg = f"Eros server error (HTTP {response.status_code}): The Eros server is experiencing issues" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), response.status_code + + except requests.exceptions.ConnectionError as e: + # Connection error - server might be down or unreachable + error_details = str(e) + + if "Connection refused" in error_details: + error_msg = f"Connection refused - Eros is not running on {api_url} or the port is incorrect" + else: + error_msg = f"Connection error - Check if Eros is running: {error_details}" + + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 502 + + except requests.exceptions.Timeout: + error_msg = f"Connection timed out - Eros took too long to respond" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 504 + + except Exception as e: + error_msg = f"Unexpected error: {str(e)}" + eros_logger.error(f"{error_msg}\n{traceback.format_exc()}") + return jsonify({"success": False, "message": error_msg}), 500 + +@eros_bp.route('/reset-processed', methods=['POST']) +def reset_processed_state(): + """Reset the processed state files for Eros""" + try: + # Reset the state files for missing and upgrades + reset_state_file("eros", "processed_missing") + reset_state_file("eros", "processed_upgrades") + + eros_logger.info("Successfully reset Eros processed state files") + return jsonify({"success": True, "message": "Successfully reset processed state"}) + except Exception as e: + error_msg = f"Error resetting Eros state: {str(e)}" + eros_logger.error(error_msg) + return jsonify({"success": False, "message": error_msg}), 500 diff --git a/src/primary/background.py b/src/primary/background.py index 0dc2af00..aa37ee8e 100644 --- a/src/primary/background.py +++ b/src/primary/background.py @@ -109,6 +109,13 @@ def app_specific_loop(app_type: str) -> None: process_upgrades = getattr(upgrade_module, 'process_cutoff_upgrades') hunt_missing_setting = "hunt_missing_items" # Updated to new name hunt_upgrade_setting = "hunt_upgrade_items" # Updated to new name + elif app_type == "eros": + missing_module = importlib.import_module('src.primary.apps.eros.missing') + upgrade_module = importlib.import_module('src.primary.apps.eros.upgrade') + process_missing = getattr(missing_module, 'process_missing_items') + process_upgrades = getattr(upgrade_module, 'process_cutoff_upgrades') + hunt_missing_setting = "hunt_missing_items" + hunt_upgrade_setting = "hunt_upgrade_items" else: app_logger.error(f"Unsupported app_type: {app_type}") return # Exit thread if app type is invalid diff --git a/src/primary/default_configs/eros.json b/src/primary/default_configs/eros.json new file mode 100644 index 00000000..29f54097 --- /dev/null +++ b/src/primary/default_configs/eros.json @@ -0,0 +1,17 @@ +{ + "instances": [ + { + "name": "Default", + "api_url": "", + "api_key": "", + "enabled": true + } + ], + "hunt_missing_items": 1, + "hunt_upgrade_items": 0, + "sleep_duration": 900, + "monitored_only": true, + "skip_series_refresh": true, + "skip_future_releases": true, + "skip_scene_refresh": true +} diff --git a/src/primary/settings_manager.py b/src/primary/settings_manager.py index 80bce83f..2e69b134 100644 --- a/src/primary/settings_manager.py +++ b/src/primary/settings_manager.py @@ -26,7 +26,7 @@ SETTINGS_DIR.mkdir(parents=True, exist_ok=True) DEFAULT_CONFIGS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), 'default_configs')) # Update or add this as a class attribute or constant -KNOWN_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "general", "swaparr"] +KNOWN_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros", "general", "swaparr"] # Add a settings cache with timestamps to avoid excessive disk reads settings_cache = {} # Format: {app_name: {'timestamp': timestamp, 'data': settings_dict}} diff --git a/src/primary/state.py b/src/primary/state.py index ceb0a6a3..50011de1 100644 --- a/src/primary/state.py +++ b/src/primary/state.py @@ -30,7 +30,7 @@ def get_state_file_path(app_type, state_name): The path to the state file """ # Define known app types - known_app_types = ["sonarr", "radarr", "lidarr", "readarr", "whisparr"] + known_app_types = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"] # If app_type is not in known types, log a warning but don't fail if app_type not in known_app_types and app_type != "general": diff --git a/src/primary/stateful_manager.py b/src/primary/stateful_manager.py index 31059644..30819f11 100644 --- a/src/primary/stateful_manager.py +++ b/src/primary/stateful_manager.py @@ -28,7 +28,7 @@ except Exception as e: stateful_logger.error(f"Error creating stateful directory: {e}") # Create app directories -APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr"] +APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "eros"] for app_type in APP_TYPES: (STATEFUL_DIR / app_type).mkdir(exist_ok=True) diff --git a/src/primary/web_server.py b/src/primary/web_server.py index bc9f8735..67a24904 100644 --- a/src/primary/web_server.py +++ b/src/primary/web_server.py @@ -37,7 +37,7 @@ from src.primary.auth import ( from src.primary.routes.common import common_bp # Import blueprints for each app from the centralized blueprints module -from src.primary.apps.blueprints import sonarr_bp, radarr_bp, lidarr_bp, readarr_bp, whisparr_bp, swaparr_bp +from src.primary.apps.blueprints import sonarr_bp, radarr_bp, lidarr_bp, readarr_bp, whisparr_bp, swaparr_bp, eros_bp # Import stateful blueprint from src.primary.stateful_routes import stateful_api @@ -64,6 +64,7 @@ app.register_blueprint(radarr_bp, url_prefix='/api/radarr') app.register_blueprint(lidarr_bp, url_prefix='/api/lidarr') app.register_blueprint(readarr_bp, url_prefix='/api/readarr') app.register_blueprint(whisparr_bp, url_prefix='/api/whisparr') +app.register_blueprint(eros_bp, url_prefix='/api/eros') app.register_blueprint(swaparr_bp, url_prefix='/api/swaparr') app.register_blueprint(stateful_api, url_prefix='/api/stateful') app.register_blueprint(history_blueprint, url_prefix='/api/history')