From dbfe5365d803a4790db6fdff187f2ff85bafcdfd Mon Sep 17 00:00:00 2001
From: Admin9705 <9705@duck.com>
Date: Sat, 3 May 2025 23:34:17 -0400
Subject: [PATCH] inital push
---
frontend/static/js/apps/eros.js | 196 +++++++++
.../templates/components/apps_section.html | 2 +
.../templates/components/history_section.html | 1 +
.../templates/components/home_section.html | 42 ++
.../templates/components/logs_section.html | 1 +
src/primary/app_manager.py | 2 +-
src/primary/apps/blueprints.py | 4 +-
src/primary/apps/eros.py | 157 ++++++++
src/primary/apps/eros/__init__.py | 84 ++++
src/primary/apps/eros/api.py | 376 ++++++++++++++++++
src/primary/apps/eros/missing.py | 225 +++++++++++
src/primary/apps/eros/upgrade.py | 188 +++++++++
src/primary/apps/eros_routes.py | 194 +++++++++
src/primary/background.py | 7 +
src/primary/default_configs/eros.json | 17 +
src/primary/settings_manager.py | 2 +-
src/primary/state.py | 2 +-
src/primary/stateful_manager.py | 2 +-
src/primary/web_server.py | 3 +-
19 files changed, 1499 insertions(+), 6 deletions(-)
create mode 100644 frontend/static/js/apps/eros.js
create mode 100644 src/primary/apps/eros.py
create mode 100644 src/primary/apps/eros/__init__.py
create mode 100644 src/primary/apps/eros/api.py
create mode 100644 src/primary/apps/eros/missing.py
create mode 100644 src/primary/apps/eros/upgrade.py
create mode 100644 src/primary/apps/eros_routes.py
create mode 100644 src/primary/default_configs/eros.json
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')