mirror of
https://github.com/plexguide/Huntarr.io.git
synced 2026-01-14 10:20:06 -06:00
inital push
This commit is contained in:
196
frontend/static/js/apps/eros.js
Normal file
196
frontend/static/js/apps/eros.js
Normal file
@@ -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;
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="#" class="log-option" data-app="lidarr">Lidarr</a>
|
||||
<a href="#" class="log-option" data-app="readarr">Readarr</a>
|
||||
<a href="#" class="log-option" data-app="whisparr">Whisparr V2</a>
|
||||
<a href="#" class="log-option" data-app="eros">Eros</a>
|
||||
<a href="#" class="log-option" data-app="swaparr">Swaparr</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,6 +26,7 @@
|
||||
<div id="lidarrApps" class="app-apps-panel app-content-panel"></div>
|
||||
<div id="readarrApps" class="app-apps-panel app-content-panel"></div>
|
||||
<div id="whisparrApps" class="app-apps-panel app-content-panel"></div>
|
||||
<div id="erosApps" class="app-apps-panel app-content-panel"></div>
|
||||
<div id="swaparrApps" class="app-apps-panel app-content-panel"></div>
|
||||
|
||||
<!-- Fixed banner at the bottom of the viewport -->
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="#" class="history-option" data-app="lidarr">Lidarr</a>
|
||||
<a href="#" class="history-option" data-app="readarr">Readarr</a>
|
||||
<a href="#" class="history-option" data-app="whisparr">Whisparr</a>
|
||||
<a href="#" class="history-option" data-app="eros">Eros</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -173,6 +173,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Eros Card -->
|
||||
<div class="app-stats-card eros">
|
||||
<div class="status-container">
|
||||
<span id="erosHomeStatus" class="status-badge loading"><i class="fas fa-spinner fa-spin"></i> Loading...</span>
|
||||
</div>
|
||||
<div class="app-content">
|
||||
<div class="app-icon-wrapper">
|
||||
<img src="/static/arrs/48-whisparr.png" alt="Eros Logo" class="app-logo">
|
||||
</div>
|
||||
<h4>Eros</h4>
|
||||
</div>
|
||||
<div class="stats-numbers">
|
||||
<div class="stat-box">
|
||||
<span class="stat-number" id="eros-hunted">0</span>
|
||||
<span class="stat-label">Searches Triggered</span>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<span class="stat-number" id="eros-upgraded">0</span>
|
||||
<span class="stat-label">Upgrades Triggered</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="#" class="log-option" data-app="lidarr">Lidarr</a>
|
||||
<a href="#" class="log-option" data-app="readarr">Readarr</a>
|
||||
<a href="#" class="log-option" data-app="whisparr">Whisparr V2</a>
|
||||
<a href="#" class="log-option" data-app="eros">Eros</a>
|
||||
<a href="#" class="log-option" data-app="swaparr">Swaparr</a>
|
||||
<a href="#" class="log-option" data-app="system">System</a>
|
||||
</div>
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
157
src/primary/apps/eros.py
Normal file
157
src/primary/apps/eros.py
Normal file
@@ -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 []
|
||||
84
src/primary/apps/eros/__init__.py
Normal file
84
src/primary/apps/eros/__init__.py
Normal file
@@ -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"]
|
||||
376
src/primary/apps/eros/api.py
Normal file
376
src/primary/apps/eros/api.py
Normal file
@@ -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
|
||||
225
src/primary/apps/eros/missing.py
Normal file
225
src/primary/apps/eros/missing.py
Normal file
@@ -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)
|
||||
188
src/primary/apps/eros/upgrade.py
Normal file
188
src/primary/apps/eros/upgrade.py
Normal file
@@ -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
|
||||
194
src/primary/apps/eros_routes.py
Normal file
194
src/primary/apps/eros_routes.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
17
src/primary/default_configs/eros.json
Normal file
17
src/primary/default_configs/eros.json
Normal file
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user