mirror of
https://github.com/plexguide/Huntarr.git
synced 2026-01-25 20:58:48 -06:00
Add Swaparr support for handling stalled downloads
- Implemented Swaparr module to manage stalled downloads in Starr apps. - Added API routes for Swaparr status and settings. - Created settings form for Swaparr in the UI. - Integrated Swaparr into the main application flow and background processing. - Updated settings manager to recognize Swaparr as a known app type. - Added default configuration for Swaparr.
This commit is contained in:
@@ -777,14 +777,41 @@ let huntarrUI = {
|
||||
const settings = {};
|
||||
const form = document.getElementById(`${app}Settings`);
|
||||
if (!form) {
|
||||
console.error(`[huntarrUI] Form not found for app: ${app}`);
|
||||
return null; // Return null if form doesn't exist
|
||||
console.error(`[huntarrUI] Settings form for ${app} not found.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
settings.instances = []; // Always initialize instances array
|
||||
// Special handling for Swaparr which has a different structure
|
||||
if (app === 'swaparr') {
|
||||
// Get all inputs directly without filtering for instance fields
|
||||
const inputs = form.querySelectorAll('input, select');
|
||||
inputs.forEach(input => {
|
||||
// Extract the field name without the app prefix
|
||||
let key = input.id;
|
||||
if (key.startsWith(`${app}_`)) {
|
||||
key = key.substring(app.length + 1);
|
||||
}
|
||||
|
||||
// Store the value based on input type
|
||||
if (input.type === 'checkbox') {
|
||||
settings[key] = input.checked;
|
||||
} else if (input.type === 'number') {
|
||||
settings[key] = input.value === '' ? null : parseInt(input.value, 10);
|
||||
} else {
|
||||
settings[key] = input.value.trim();
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[huntarrUI] Collected Swaparr settings:`, settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
// Check if multi-instance UI elements exist (like Sonarr)
|
||||
// Handle apps that use instances (Sonarr, Radarr, etc.)
|
||||
// Get all instance items in the form
|
||||
const instanceItems = form.querySelectorAll('.instance-item');
|
||||
settings.instances = [];
|
||||
|
||||
// Check if multi-instance UI elements exist (like Sonarr)
|
||||
if (instanceItems.length > 0) {
|
||||
console.log(`[huntarrUI] Found ${instanceItems.length} instance items for ${app}. Processing multi-instance mode.`);
|
||||
// Multi-instance logic (current Sonarr logic)
|
||||
|
||||
@@ -900,6 +900,138 @@ const SettingsForms = {
|
||||
SettingsForms.setupInstanceManagement(container, 'whisparr', settings.instances.length);
|
||||
},
|
||||
|
||||
// Generate Swaparr settings form
|
||||
generateSwaparrForm: function(container, settings = {}) {
|
||||
// Create the HTML for the Swaparr settings form
|
||||
container.innerHTML = `
|
||||
<div class="settings-group">
|
||||
<h3>Swaparr Settings</h3>
|
||||
<div class="setting-item">
|
||||
<label for="swaparr_enabled">Enable Swaparr:</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="swaparr_enabled" ${settings.enabled ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<p class="setting-help">Enable automatic handling of stalled downloads</p>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="swaparr_max_strikes">Maximum Strikes:</label>
|
||||
<input type="number" id="swaparr_max_strikes" min="1" max="10" value="${settings.max_strikes || 3}">
|
||||
<p class="setting-help">Number of strikes before removing a stalled download</p>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="swaparr_max_download_time">Max Download Time:</label>
|
||||
<input type="text" id="swaparr_max_download_time" value="${settings.max_download_time || '2h'}">
|
||||
<p class="setting-help">Maximum time a download can be stalled (e.g., 30m, 2h, 1d)</p>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="swaparr_ignore_above_size">Ignore Above Size:</label>
|
||||
<input type="text" id="swaparr_ignore_above_size" value="${settings.ignore_above_size || '25GB'}">
|
||||
<p class="setting-help">Ignore files larger than this size (e.g., 1GB, 25GB, 1TB)</p>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="swaparr_remove_from_client">Remove From Client:</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="swaparr_remove_from_client" ${settings.remove_from_client !== false ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<p class="setting-help">Remove the download from the torrent/usenet client when removed</p>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="swaparr_dry_run">Dry Run Mode:</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="swaparr_dry_run" ${settings.dry_run === true ? 'checked' : ''}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<p class="setting-help">Log actions but don't actually remove downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3>Swaparr Status</h3>
|
||||
<div id="swaparr_status_container">
|
||||
<div class="button-container" style="display: flex; gap: 10px; margin-bottom: 15px;">
|
||||
<button type="button" id="check_swaparr_status" class="action-button">Check Status</button>
|
||||
<button type="button" id="reset_swaparr_strikes" class="action-button">Reset All Strikes</button>
|
||||
</div>
|
||||
<div id="swaparr_status" class="status-display">
|
||||
<p>Click "Check Status" to view the current status of Swaparr</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners for the Swaparr-specific buttons
|
||||
const checkStatusBtn = container.querySelector('#check_swaparr_status');
|
||||
const resetStrikesBtn = container.querySelector('#reset_swaparr_strikes');
|
||||
const statusContainer = container.querySelector('#swaparr_status');
|
||||
|
||||
if (checkStatusBtn) {
|
||||
checkStatusBtn.addEventListener('click', function() {
|
||||
statusContainer.innerHTML = '<p>Loading status...</p>';
|
||||
|
||||
fetch('/api/swaparr/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
let statusHTML = '<h4>Swaparr Status</h4>';
|
||||
|
||||
// Add general status
|
||||
statusHTML += `<p>Swaparr is currently <strong>${data.enabled ? 'ENABLED' : 'DISABLED'}</strong></p>`;
|
||||
|
||||
// Add stats for each app if available
|
||||
if (data.statistics && Object.keys(data.statistics).length > 0) {
|
||||
statusHTML += '<h4>Statistics by App</h4><ul>';
|
||||
|
||||
for (const [app, stats] of Object.entries(data.statistics)) {
|
||||
statusHTML += `<li><strong>${app.toUpperCase()}</strong>: `;
|
||||
if (stats.error) {
|
||||
statusHTML += `Error: ${stats.error}</li>`;
|
||||
} else {
|
||||
statusHTML += `${stats.currently_striked} currently striked, ${stats.removed} removed (${stats.total_tracked} total tracked)</li>`;
|
||||
}
|
||||
}
|
||||
|
||||
statusHTML += '</ul>';
|
||||
} else {
|
||||
statusHTML += '<p>No statistics available yet.</p>';
|
||||
}
|
||||
|
||||
statusContainer.innerHTML = statusHTML;
|
||||
})
|
||||
.catch(error => {
|
||||
statusContainer.innerHTML = `<p>Error fetching status: ${error.message}</p>`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (resetStrikesBtn) {
|
||||
resetStrikesBtn.addEventListener('click', function() {
|
||||
if (confirm('Are you sure you want to reset all Swaparr strikes? This will clear the strike history for all apps.')) {
|
||||
statusContainer.innerHTML = '<p>Resetting strikes...</p>';
|
||||
|
||||
fetch('/api/swaparr/reset', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
statusContainer.innerHTML = `<p>Success: ${data.message}</p>`;
|
||||
} else {
|
||||
statusContainer.innerHTML = `<p>Error: ${data.message}</p>`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
statusContainer.innerHTML = `<p>Error resetting strikes: ${error.message}</p>`;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Generate General settings form
|
||||
generateGeneralForm: function(container, settings = {}) {
|
||||
container.innerHTML = `
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<button class="settings-tab" data-app="lidarr">Lidarr</button>
|
||||
<button class="settings-tab" data-app="readarr">Readarr</button>
|
||||
<button class="settings-tab" data-app="whisparr">Whisparr</button>
|
||||
<button class="settings-tab" data-app="swaparr">Swaparr</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
@@ -23,6 +24,7 @@
|
||||
<div id="lidarrSettings" class="app-settings-panel"></div>
|
||||
<div id="readarrSettings" class="app-settings-panel"></div>
|
||||
<div id="whisparrSettings" class="app-settings-panel"></div>
|
||||
<div id="swaparrSettings" class="app-settings-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ from src.primary.apps.radarr_routes import radarr_bp
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"sonarr_bp",
|
||||
"radarr_bp",
|
||||
"lidarr_bp",
|
||||
"readarr_bp",
|
||||
"whisparr_bp"
|
||||
"whisparr_bp",
|
||||
"swaparr_bp"
|
||||
]
|
||||
174
src/primary/apps/swaparr.py
Normal file
174
src/primary/apps/swaparr.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Swaparr module for Huntarr
|
||||
Handles stalled downloads in Starr apps based on the original Swaparr application
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
import os
|
||||
import json
|
||||
from src.primary.utils.logger import get_logger
|
||||
from src.primary.settings_manager import load_settings, save_settings
|
||||
from src.primary.apps.swaparr.handler import process_stalled_downloads
|
||||
from src.primary.apps.radarr import get_configured_instances as get_radarr_instances
|
||||
from src.primary.apps.sonarr import get_configured_instances as get_sonarr_instances
|
||||
from src.primary.apps.lidarr import get_configured_instances as get_lidarr_instances
|
||||
from src.primary.apps.readarr import get_configured_instances as get_readarr_instances
|
||||
|
||||
def get_configured_instances():
|
||||
"""Get all configured Starr app instances from their respective settings"""
|
||||
try:
|
||||
from src.primary.apps.whisparr import get_configured_instances as get_whisparr_instances
|
||||
whisparr_instances = get_whisparr_instances()
|
||||
except ImportError:
|
||||
whisparr_instances = []
|
||||
|
||||
instances = {
|
||||
"radarr": get_radarr_instances(),
|
||||
"sonarr": get_sonarr_instances(),
|
||||
"lidarr": get_lidarr_instances(),
|
||||
"readarr": get_readarr_instances(),
|
||||
"whisparr": whisparr_instances
|
||||
}
|
||||
|
||||
logger = get_logger("swaparr")
|
||||
logger.info(f"Found {sum(len(v) for v in instances.values())} configured Starr app instances")
|
||||
return instances
|
||||
|
||||
swaparr_bp = Blueprint('swaparr', __name__)
|
||||
swaparr_logger = get_logger("swaparr")
|
||||
|
||||
@swaparr_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get Swaparr status and statistics"""
|
||||
settings = load_settings("swaparr")
|
||||
enabled = settings.get("enabled", False)
|
||||
|
||||
# Get strike statistics from all app state directories
|
||||
statistics = {}
|
||||
state_dir = os.path.join(os.getenv("CONFIG_DIR", "/config"), "swaparr")
|
||||
|
||||
if os.path.exists(state_dir):
|
||||
for app_name in os.listdir(state_dir):
|
||||
app_dir = os.path.join(state_dir, app_name)
|
||||
if os.path.isdir(app_dir):
|
||||
strike_file = os.path.join(app_dir, "strikes.json")
|
||||
if os.path.exists(strike_file):
|
||||
try:
|
||||
with open(strike_file, 'r') as f:
|
||||
strike_data = json.load(f)
|
||||
|
||||
total_items = len(strike_data)
|
||||
removed_items = sum(1 for item in strike_data.values() if item.get("removed", False))
|
||||
striked_items = sum(1 for item in strike_data.values()
|
||||
if item.get("strikes", 0) > 0 and not item.get("removed", False))
|
||||
|
||||
statistics[app_name] = {
|
||||
"total_tracked": total_items,
|
||||
"currently_striked": striked_items,
|
||||
"removed": removed_items
|
||||
}
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
swaparr_logger.error(f"Error reading strike data for {app_name}: {str(e)}")
|
||||
statistics[app_name] = {"error": str(e)}
|
||||
|
||||
return jsonify({
|
||||
"enabled": enabled,
|
||||
"settings": {
|
||||
"max_strikes": settings.get("max_strikes", 3),
|
||||
"max_download_time": settings.get("max_download_time", "2h"),
|
||||
"ignore_above_size": settings.get("ignore_above_size", "25GB"),
|
||||
"remove_from_client": settings.get("remove_from_client", True),
|
||||
"dry_run": settings.get("dry_run", False)
|
||||
},
|
||||
"statistics": statistics
|
||||
})
|
||||
|
||||
@swaparr_bp.route('/settings', methods=['GET'])
|
||||
def get_settings():
|
||||
"""Get Swaparr settings"""
|
||||
settings = load_settings("swaparr")
|
||||
return jsonify(settings)
|
||||
|
||||
@swaparr_bp.route('/settings', methods=['POST'])
|
||||
def update_settings():
|
||||
"""Update Swaparr settings"""
|
||||
data = request.json
|
||||
|
||||
if not data:
|
||||
return jsonify({"success": False, "message": "No data provided"}), 400
|
||||
|
||||
# Load current settings
|
||||
settings = load_settings("swaparr")
|
||||
|
||||
# Update settings with provided data
|
||||
for key, value in data.items():
|
||||
settings[key] = value
|
||||
|
||||
# Save updated settings
|
||||
success = save_settings("swaparr", settings)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": "Settings updated successfully"})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Failed to save settings"}), 500
|
||||
|
||||
@swaparr_bp.route('/reset', methods=['POST'])
|
||||
def reset_strikes():
|
||||
"""Reset all strikes for all apps or a specific app"""
|
||||
data = request.json
|
||||
app_name = data.get('app_name') if data else None
|
||||
|
||||
state_dir = os.path.join(os.getenv("CONFIG_DIR", "/config"), "swaparr")
|
||||
|
||||
if not os.path.exists(state_dir):
|
||||
return jsonify({"success": True, "message": "No strike data to reset"})
|
||||
|
||||
if app_name:
|
||||
# Reset strikes for a specific app
|
||||
app_dir = os.path.join(state_dir, app_name)
|
||||
if os.path.exists(app_dir):
|
||||
strike_file = os.path.join(app_dir, "strikes.json")
|
||||
if os.path.exists(strike_file):
|
||||
try:
|
||||
os.remove(strike_file)
|
||||
swaparr_logger.info(f"Reset strikes for {app_name}")
|
||||
return jsonify({"success": True, "message": f"Strikes reset for {app_name}"})
|
||||
except IOError as e:
|
||||
swaparr_logger.error(f"Error resetting strikes for {app_name}: {str(e)}")
|
||||
return jsonify({"success": False, "message": f"Failed to reset strikes for {app_name}: {str(e)}"}), 500
|
||||
return jsonify({"success": False, "message": f"No strike data found for {app_name}"}), 404
|
||||
else:
|
||||
# Reset strikes for all apps
|
||||
try:
|
||||
for app_name in os.listdir(state_dir):
|
||||
app_dir = os.path.join(state_dir, app_name)
|
||||
if os.path.isdir(app_dir):
|
||||
strike_file = os.path.join(app_dir, "strikes.json")
|
||||
if os.path.exists(strike_file):
|
||||
os.remove(strike_file)
|
||||
|
||||
swaparr_logger.info("Reset all strikes")
|
||||
return jsonify({"success": True, "message": "All strikes reset"})
|
||||
except IOError as e:
|
||||
swaparr_logger.error(f"Error resetting all strikes: {str(e)}")
|
||||
return jsonify({"success": False, "message": f"Failed to reset all strikes: {str(e)}"}), 500
|
||||
|
||||
def is_configured():
|
||||
"""Check if Swaparr has any configured Starr app instances"""
|
||||
instances = get_configured_instances()
|
||||
return any(len(app_instances) > 0 for app_instances in instances.values())
|
||||
|
||||
def run_swaparr():
|
||||
"""Run Swaparr cycle to check for stalled downloads in all configured Starr app instances"""
|
||||
settings = load_settings("swaparr")
|
||||
|
||||
if not settings or not settings.get("enabled", False):
|
||||
swaparr_logger.debug("Swaparr is disabled, skipping run")
|
||||
return
|
||||
|
||||
instances = get_configured_instances()
|
||||
|
||||
# Process stalled downloads for each app type and instance
|
||||
for app_name, app_instances in instances.items():
|
||||
for app_settings in app_instances:
|
||||
process_stalled_downloads(app_name, app_settings, settings)
|
||||
16
src/primary/apps/swaparr/__init__.py
Normal file
16
src/primary/apps/swaparr/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Swaparr app module for Huntarr
|
||||
Contains functionality for handling stalled downloads in Starr apps
|
||||
"""
|
||||
|
||||
# Add necessary imports for get_configured_instances
|
||||
from src.primary.settings_manager import load_settings
|
||||
from src.primary.utils.logger import get_logger
|
||||
|
||||
swaparr_logger = get_logger("swaparr") # Get the logger instance
|
||||
|
||||
# We don't need the get_configured_instances function here anymore as it's defined in swaparr.py
|
||||
# to avoid circular imports
|
||||
|
||||
# Export just the swaparr_logger for now
|
||||
__all__ = ["swaparr_logger"]
|
||||
329
src/primary/apps/swaparr/handler.py
Normal file
329
src/primary/apps/swaparr/handler.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Implementation of the swaparr functionality to detect and remove stalled downloads in Starr apps.
|
||||
Based on the functionality provided by https://github.com/ThijmenGThN/swaparr
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
import requests
|
||||
|
||||
from src.primary.utils.logger import get_logger
|
||||
from src.primary.settings_manager import load_settings
|
||||
from src.primary.state import get_state_file_path
|
||||
|
||||
# Create logger
|
||||
swaparr_logger = get_logger("swaparr")
|
||||
|
||||
# Create state directory for tracking strikes
|
||||
SWAPARR_STATE_DIR = os.path.join(os.getenv("CONFIG_DIR", "/config"), "swaparr")
|
||||
|
||||
def ensure_state_directory(app_name):
|
||||
"""Ensure the state directory exists for tracking strikes for a specific app"""
|
||||
app_state_dir = os.path.join(SWAPARR_STATE_DIR, app_name)
|
||||
if not os.path.exists(app_state_dir):
|
||||
os.makedirs(app_state_dir, exist_ok=True)
|
||||
swaparr_logger.info(f"Created swaparr state directory for {app_name}: {app_state_dir}")
|
||||
return app_state_dir
|
||||
|
||||
def load_strike_data(app_name):
|
||||
"""Load strike data for a specific app"""
|
||||
app_state_dir = ensure_state_directory(app_name)
|
||||
strike_file = os.path.join(app_state_dir, "strikes.json")
|
||||
|
||||
if not os.path.exists(strike_file):
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(strike_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
swaparr_logger.error(f"Error loading strike data for {app_name}: {str(e)}")
|
||||
return {}
|
||||
|
||||
def save_strike_data(app_name, strike_data):
|
||||
"""Save strike data for a specific app"""
|
||||
app_state_dir = ensure_state_directory(app_name)
|
||||
strike_file = os.path.join(app_state_dir, "strikes.json")
|
||||
|
||||
try:
|
||||
with open(strike_file, 'w') as f:
|
||||
json.dump(strike_data, f, indent=2)
|
||||
except IOError as e:
|
||||
swaparr_logger.error(f"Error saving strike data for {app_name}: {str(e)}")
|
||||
|
||||
def parse_time_string_to_seconds(time_string):
|
||||
"""Parse a time string like '2h', '30m', '1d' to seconds"""
|
||||
if not time_string:
|
||||
return 7200 # Default 2 hours
|
||||
|
||||
unit = time_string[-1].lower()
|
||||
try:
|
||||
value = int(time_string[:-1])
|
||||
except ValueError:
|
||||
swaparr_logger.error(f"Invalid time string: {time_string}, using default 2 hours")
|
||||
return 7200
|
||||
|
||||
if unit == 'd':
|
||||
return value * 86400 # Days to seconds
|
||||
elif unit == 'h':
|
||||
return value * 3600 # Hours to seconds
|
||||
elif unit == 'm':
|
||||
return value * 60 # Minutes to seconds
|
||||
else:
|
||||
swaparr_logger.error(f"Unknown time unit in: {time_string}, using default 2 hours")
|
||||
return 7200
|
||||
|
||||
def parse_size_string_to_bytes(size_string):
|
||||
"""Parse a size string like '25GB', '1TB' to bytes"""
|
||||
if not size_string:
|
||||
return 25 * 1024 * 1024 * 1024 # Default 25GB
|
||||
|
||||
# Extract the numeric part and unit
|
||||
unit = ""
|
||||
for i in range(len(size_string) - 1, -1, -1):
|
||||
if not size_string[i].isalpha():
|
||||
value = float(size_string[:i+1])
|
||||
unit = size_string[i+1:].upper()
|
||||
break
|
||||
else:
|
||||
swaparr_logger.error(f"Invalid size string: {size_string}, using default 25GB")
|
||||
return 25 * 1024 * 1024 * 1024
|
||||
|
||||
# Convert to bytes based on unit
|
||||
if unit == 'B':
|
||||
return int(value)
|
||||
elif unit == 'KB':
|
||||
return int(value * 1024)
|
||||
elif unit == 'MB':
|
||||
return int(value * 1024 * 1024)
|
||||
elif unit == 'GB':
|
||||
return int(value * 1024 * 1024 * 1024)
|
||||
elif unit == 'TB':
|
||||
return int(value * 1024 * 1024 * 1024 * 1024)
|
||||
else:
|
||||
swaparr_logger.error(f"Unknown size unit in: {size_string}, using default 25GB")
|
||||
return 25 * 1024 * 1024 * 1024
|
||||
|
||||
def get_queue_items(app_name, api_url, api_key, api_timeout=120):
|
||||
"""Get download queue items from a Starr app API"""
|
||||
api_version_map = {
|
||||
"radarr": "v3",
|
||||
"sonarr": "v3",
|
||||
"lidarr": "v1",
|
||||
"readarr": "v1",
|
||||
"whisparr": "v3"
|
||||
}
|
||||
|
||||
api_version = api_version_map.get(app_name, "v3")
|
||||
queue_url = f"{api_url.rstrip('/')}/api/{api_version}/queue"
|
||||
headers = {'X-Api-Key': api_key}
|
||||
|
||||
try:
|
||||
response = requests.get(queue_url, headers=headers, timeout=api_timeout)
|
||||
response.raise_for_status()
|
||||
queue_data = response.json()
|
||||
|
||||
# Normalize the response based on app type
|
||||
if app_name in ["radarr", "whisparr"]:
|
||||
return parse_queue_items(queue_data["records"], "movie", app_name)
|
||||
elif app_name == "sonarr":
|
||||
return parse_queue_items(queue_data["records"], "series", app_name)
|
||||
elif app_name == "lidarr":
|
||||
return parse_queue_items(queue_data["records"], "album", app_name)
|
||||
elif app_name == "readarr":
|
||||
return parse_queue_items(queue_data["records"], "book", app_name)
|
||||
else:
|
||||
swaparr_logger.error(f"Unknown app type: {app_name}")
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
swaparr_logger.error(f"Error fetching queue for {app_name}: {str(e)}")
|
||||
return []
|
||||
|
||||
def parse_queue_items(records, item_type, app_name):
|
||||
"""Parse queue items from API response into a standardized format"""
|
||||
queue_items = []
|
||||
|
||||
for record in records:
|
||||
# Extract the name based on the item type
|
||||
name = None
|
||||
if item_type == "movie" and record.get("movie"):
|
||||
name = record["movie"].get("title", "Unknown Movie")
|
||||
elif item_type == "series" and record.get("series"):
|
||||
name = record["series"].get("title", "Unknown Series")
|
||||
elif item_type == "album" and record.get("album"):
|
||||
name = record["album"].get("title", "Unknown Album")
|
||||
elif item_type == "book" and record.get("book"):
|
||||
name = record["book"].get("title", "Unknown Book")
|
||||
|
||||
# If no name was found, try to use the download title
|
||||
if not name and record.get("title"):
|
||||
name = record.get("title", "Unknown Download")
|
||||
|
||||
# Parse ETA if available
|
||||
eta_seconds = 0
|
||||
if record.get("timeleft"):
|
||||
eta = record.get("timeleft", "")
|
||||
# Basic parsing of timeleft format like "00:30:00" (30 minutes)
|
||||
try:
|
||||
eta_parts = eta.split(':')
|
||||
if len(eta_parts) == 3:
|
||||
eta_seconds = int(eta_parts[0]) * 3600 + int(eta_parts[1]) * 60 + int(eta_parts[2])
|
||||
except (ValueError, IndexError):
|
||||
eta_seconds = 0
|
||||
|
||||
queue_items.append({
|
||||
"id": record.get("id"),
|
||||
"name": name,
|
||||
"size": record.get("size", 0),
|
||||
"status": record.get("status", "unknown").lower(),
|
||||
"eta": eta_seconds,
|
||||
"error_message": record.get("errorMessage", "")
|
||||
})
|
||||
|
||||
return queue_items
|
||||
|
||||
def delete_download(app_name, api_url, api_key, download_id, remove_from_client=True, api_timeout=120):
|
||||
"""Delete a download from a Starr app"""
|
||||
api_version_map = {
|
||||
"radarr": "v3",
|
||||
"sonarr": "v3",
|
||||
"lidarr": "v1",
|
||||
"readarr": "v1",
|
||||
"whisparr": "v3"
|
||||
}
|
||||
|
||||
api_version = api_version_map.get(app_name, "v3")
|
||||
delete_url = f"{api_url.rstrip('/')}/api/{api_version}/queue/{download_id}?removeFromClient={str(remove_from_client).lower()}&blocklist=true"
|
||||
headers = {'X-Api-Key': api_key}
|
||||
|
||||
try:
|
||||
response = requests.delete(delete_url, headers=headers, timeout=api_timeout)
|
||||
response.raise_for_status()
|
||||
swaparr_logger.info(f"Successfully removed download {download_id} from {app_name}")
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
swaparr_logger.error(f"Error removing download {download_id} from {app_name}: {str(e)}")
|
||||
return False
|
||||
|
||||
def process_stalled_downloads(app_name, app_settings, swaparr_settings=None):
|
||||
"""Process stalled downloads for a specific app instance"""
|
||||
if not swaparr_settings:
|
||||
swaparr_settings = load_settings("swaparr")
|
||||
|
||||
if not swaparr_settings or not swaparr_settings.get("enabled", False):
|
||||
swaparr_logger.debug(f"Swaparr is disabled, skipping {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
|
||||
return
|
||||
|
||||
swaparr_logger.info(f"Processing stalled downloads for {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
|
||||
|
||||
# Get settings
|
||||
max_strikes = swaparr_settings.get("max_strikes", 3)
|
||||
max_download_time = parse_time_string_to_seconds(swaparr_settings.get("max_download_time", "2h"))
|
||||
ignore_above_size = parse_size_string_to_bytes(swaparr_settings.get("ignore_above_size", "25GB"))
|
||||
remove_from_client = swaparr_settings.get("remove_from_client", True)
|
||||
dry_run = swaparr_settings.get("dry_run", False)
|
||||
|
||||
api_url = app_settings.get("api_url")
|
||||
api_key = app_settings.get("api_key")
|
||||
api_timeout = app_settings.get("api_timeout", 120)
|
||||
|
||||
if not api_url or not api_key:
|
||||
swaparr_logger.error(f"Missing API URL or API Key for {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
|
||||
return
|
||||
|
||||
# Load existing strike data
|
||||
strike_data = load_strike_data(app_name)
|
||||
|
||||
# Get current queue items
|
||||
queue_items = get_queue_items(app_name, api_url, api_key, api_timeout)
|
||||
|
||||
if not queue_items:
|
||||
swaparr_logger.info(f"No queue items found for {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
|
||||
return
|
||||
|
||||
# Keep track of items still in queue for cleanup
|
||||
current_item_ids = set(item["id"] for item in queue_items)
|
||||
|
||||
# Clean up items that are no longer in the queue
|
||||
for item_id in list(strike_data.keys()):
|
||||
if int(item_id) not in current_item_ids:
|
||||
swaparr_logger.debug(f"Removing item {item_id} from strike list as it's no longer in the queue")
|
||||
del strike_data[item_id]
|
||||
|
||||
# Process each queue item
|
||||
for item in queue_items:
|
||||
item_id = str(item["id"])
|
||||
item_state = "Normal"
|
||||
|
||||
# Skip large files if configured
|
||||
if item["size"] >= ignore_above_size:
|
||||
swaparr_logger.debug(f"Ignoring large download: {item['name']} ({item['size']} bytes > {ignore_above_size} bytes)")
|
||||
item_state = "Ignored (Size)"
|
||||
continue
|
||||
|
||||
# Skip queued or delayed items
|
||||
if item["status"] in ["queued", "delay"]:
|
||||
swaparr_logger.debug(f"Ignoring {item['status']} download: {item['name']}")
|
||||
item_state = f"Ignored ({item['status'].capitalize()})"
|
||||
continue
|
||||
|
||||
# Initialize strike count if not already in strike data
|
||||
if item_id not in strike_data:
|
||||
strike_data[item_id] = {
|
||||
"strikes": 0,
|
||||
"name": item["name"],
|
||||
"first_strike_time": None,
|
||||
"last_strike_time": None
|
||||
}
|
||||
|
||||
# Check if download should be striked
|
||||
should_strike = False
|
||||
|
||||
# Strike if metadata, eta too long, or no progress (eta = 0 and not queued)
|
||||
if "metadata" in item["status"].lower() or "metadata" in item["error_message"].lower():
|
||||
should_strike = True
|
||||
strike_reason = "Metadata"
|
||||
elif item["eta"] >= max_download_time:
|
||||
should_strike = True
|
||||
strike_reason = "ETA too long"
|
||||
elif item["eta"] == 0 and item["status"] not in ["queued", "delay"]:
|
||||
should_strike = True
|
||||
strike_reason = "No progress"
|
||||
|
||||
# If we should strike this item, add a strike
|
||||
if should_strike:
|
||||
strike_data[item_id]["strikes"] += 1
|
||||
strike_data[item_id]["last_strike_time"] = datetime.utcnow().isoformat()
|
||||
|
||||
if strike_data[item_id]["first_strike_time"] is None:
|
||||
strike_data[item_id]["first_strike_time"] = datetime.utcnow().isoformat()
|
||||
|
||||
current_strikes = strike_data[item_id]["strikes"]
|
||||
swaparr_logger.info(f"Added strike ({current_strikes}/{max_strikes}) to {item['name']} - Reason: {strike_reason}")
|
||||
|
||||
# If max strikes reached, remove the download
|
||||
if current_strikes >= max_strikes:
|
||||
swaparr_logger.warning(f"Max strikes reached for {item['name']}, removing download")
|
||||
|
||||
if not dry_run:
|
||||
if delete_download(app_name, api_url, api_key, item["id"], remove_from_client, api_timeout):
|
||||
swaparr_logger.info(f"Successfully removed {item['name']} after {max_strikes} strikes")
|
||||
# Keep the item in strike data for reference but mark as removed
|
||||
strike_data[item_id]["removed"] = True
|
||||
strike_data[item_id]["removed_time"] = datetime.utcnow().isoformat()
|
||||
else:
|
||||
swaparr_logger.info(f"DRY RUN: Would have removed {item['name']} after {max_strikes} strikes")
|
||||
|
||||
item_state = "Removed" if not dry_run else "Would Remove (Dry Run)"
|
||||
else:
|
||||
item_state = f"Striked ({current_strikes}/{max_strikes})"
|
||||
|
||||
swaparr_logger.debug(f"Processed download: {item['name']} - State: {item_state}")
|
||||
|
||||
# Save updated strike data
|
||||
save_strike_data(app_name, strike_data)
|
||||
|
||||
swaparr_logger.info(f"Finished processing stalled downloads for {app_name} instance: {app_settings.get('instance_name', 'Unknown')}")
|
||||
134
src/primary/apps/swaparr_routes.py
Normal file
134
src/primary/apps/swaparr_routes.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Route definitions for Swaparr API endpoints.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
import os
|
||||
import json
|
||||
from src.primary.utils.logger import get_logger
|
||||
from src.primary.settings_manager import load_settings, save_settings
|
||||
from src.primary.apps.swaparr.handler import process_stalled_downloads
|
||||
|
||||
# Create the blueprint directly in this file
|
||||
swaparr_bp = Blueprint('swaparr', __name__)
|
||||
swaparr_logger = get_logger("swaparr")
|
||||
|
||||
@swaparr_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""Get Swaparr status and statistics"""
|
||||
settings = load_settings("swaparr")
|
||||
enabled = settings.get("enabled", False)
|
||||
|
||||
# Get strike statistics from all app state directories
|
||||
statistics = {}
|
||||
state_dir = os.path.join(os.getenv("CONFIG_DIR", "/config"), "swaparr")
|
||||
|
||||
if os.path.exists(state_dir):
|
||||
for app_name in os.listdir(state_dir):
|
||||
app_dir = os.path.join(state_dir, app_name)
|
||||
if os.path.isdir(app_dir):
|
||||
strike_file = os.path.join(app_dir, "strikes.json")
|
||||
if os.path.exists(strike_file):
|
||||
try:
|
||||
with open(strike_file, 'r') as f:
|
||||
strike_data = json.load(f)
|
||||
|
||||
total_items = len(strike_data)
|
||||
removed_items = sum(1 for item in strike_data.values() if item.get("removed", False))
|
||||
striked_items = sum(1 for item in strike_data.values()
|
||||
if item.get("strikes", 0) > 0 and not item.get("removed", False))
|
||||
|
||||
statistics[app_name] = {
|
||||
"total_tracked": total_items,
|
||||
"currently_striked": striked_items,
|
||||
"removed": removed_items
|
||||
}
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
swaparr_logger.error(f"Error reading strike data for {app_name}: {str(e)}")
|
||||
statistics[app_name] = {"error": str(e)}
|
||||
|
||||
return jsonify({
|
||||
"enabled": enabled,
|
||||
"settings": {
|
||||
"max_strikes": settings.get("max_strikes", 3),
|
||||
"max_download_time": settings.get("max_download_time", "2h"),
|
||||
"ignore_above_size": settings.get("ignore_above_size", "25GB"),
|
||||
"remove_from_client": settings.get("remove_from_client", True),
|
||||
"dry_run": settings.get("dry_run", False)
|
||||
},
|
||||
"statistics": statistics
|
||||
})
|
||||
|
||||
@swaparr_bp.route('/settings', methods=['GET'])
|
||||
def get_settings():
|
||||
"""Get Swaparr settings"""
|
||||
settings = load_settings("swaparr")
|
||||
return jsonify(settings)
|
||||
|
||||
@swaparr_bp.route('/settings', methods=['POST'])
|
||||
def update_settings():
|
||||
"""Update Swaparr settings"""
|
||||
data = request.json
|
||||
|
||||
if not data:
|
||||
return jsonify({"success": False, "message": "No data provided"}), 400
|
||||
|
||||
# Load current settings
|
||||
settings = load_settings("swaparr")
|
||||
|
||||
# Update settings with provided data
|
||||
for key, value in data.items():
|
||||
settings[key] = value
|
||||
|
||||
# Save updated settings
|
||||
success = save_settings("swaparr", settings)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": "Settings updated successfully"})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Failed to save settings"}), 500
|
||||
|
||||
@swaparr_bp.route('/reset', methods=['POST'])
|
||||
def reset_strikes():
|
||||
"""Reset all strikes for all apps or a specific app"""
|
||||
data = request.json
|
||||
app_name = data.get('app_name') if data else None
|
||||
|
||||
state_dir = os.path.join(os.getenv("CONFIG_DIR", "/config"), "swaparr")
|
||||
|
||||
if not os.path.exists(state_dir):
|
||||
return jsonify({"success": True, "message": "No strike data to reset"})
|
||||
|
||||
if app_name:
|
||||
# Reset strikes for a specific app
|
||||
app_dir = os.path.join(state_dir, app_name)
|
||||
if os.path.exists(app_dir):
|
||||
strike_file = os.path.join(app_dir, "strikes.json")
|
||||
if os.path.exists(strike_file):
|
||||
try:
|
||||
os.remove(strike_file)
|
||||
swaparr_logger.info(f"Reset strikes for {app_name}")
|
||||
return jsonify({"success": True, "message": f"Strikes reset for {app_name}"})
|
||||
except IOError as e:
|
||||
swaparr_logger.error(f"Error resetting strikes for {app_name}: {str(e)}")
|
||||
return jsonify({"success": False, "message": f"Failed to reset strikes for {app_name}: {str(e)}"}), 500
|
||||
return jsonify({"success": False, "message": f"No strike data found for {app_name}"}), 404
|
||||
else:
|
||||
# Reset strikes for all apps
|
||||
try:
|
||||
for app_name in os.listdir(state_dir):
|
||||
app_dir = os.path.join(state_dir, app_name)
|
||||
if os.path.isdir(app_dir):
|
||||
strike_file = os.path.join(app_dir, "strikes.json")
|
||||
if os.path.exists(strike_file):
|
||||
os.remove(strike_file)
|
||||
|
||||
swaparr_logger.info("Reset all strikes")
|
||||
return jsonify({"success": True, "message": "All strikes reset"})
|
||||
except IOError as e:
|
||||
swaparr_logger.error(f"Error resetting all strikes: {str(e)}")
|
||||
return jsonify({"success": False, "message": f"Failed to reset all strikes: {str(e)}"}), 500
|
||||
|
||||
def register_routes(app):
|
||||
"""Register Swaparr routes with the Flask app."""
|
||||
app.register_blueprint(swaparr_bp, url_prefix='/api/swaparr')
|
||||
@@ -330,6 +330,28 @@ def app_specific_loop(app_type: str) -> None:
|
||||
if not stop_event.is_set():
|
||||
time.sleep(1) # Short pause
|
||||
|
||||
# --- Process Swaparr (stalled downloads) --- #
|
||||
try:
|
||||
# Try to import Swaparr module
|
||||
if not 'process_stalled_downloads' in locals():
|
||||
try:
|
||||
# Import directly from handler module to avoid circular imports
|
||||
from src.primary.apps.swaparr.handler import process_stalled_downloads
|
||||
swaparr_logger = get_logger("swaparr")
|
||||
swaparr_logger.debug(f"Successfully imported Swaparr module")
|
||||
except (ImportError, AttributeError) as e:
|
||||
app_logger.debug(f"Swaparr module not available or missing functions: {e}")
|
||||
process_stalled_downloads = None
|
||||
|
||||
# Check if Swaparr is enabled
|
||||
swaparr_settings = settings_manager.load_settings("swaparr")
|
||||
if swaparr_settings and swaparr_settings.get("enabled", False) and process_stalled_downloads:
|
||||
app_logger.info(f"Running Swaparr on {app_type} instance: {instance_name}")
|
||||
process_stalled_downloads(app_type, combined_settings, swaparr_settings)
|
||||
app_logger.info(f"Completed Swaparr processing for {app_type} instance: {instance_name}")
|
||||
except Exception as e:
|
||||
app_logger.error(f"Error during Swaparr processing for {instance_name}: {e}", exc_info=True)
|
||||
|
||||
# --- Cycle End & Sleep --- #
|
||||
calculate_reset_time(app_type) # Pass app_type here if needed by the function
|
||||
|
||||
|
||||
8
src/primary/default_configs/swaparr.json
Normal file
8
src/primary/default_configs/swaparr.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"max_strikes": 3,
|
||||
"max_download_time": "2h",
|
||||
"ignore_above_size": "25GB",
|
||||
"remove_from_client": true,
|
||||
"dry_run": false
|
||||
}
|
||||
@@ -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"]
|
||||
KNOWN_APP_TYPES = ["sonarr", "radarr", "lidarr", "readarr", "whisparr", "general", "swaparr"]
|
||||
|
||||
# Add a settings cache with timestamps to avoid excessive disk reads
|
||||
settings_cache = {} # Format: {app_name: {'timestamp': timestamp, 'data': settings_dict}}
|
||||
|
||||
@@ -36,7 +36,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
|
||||
from src.primary.apps.blueprints import sonarr_bp, radarr_bp, lidarr_bp, readarr_bp, whisparr_bp, swaparr_bp
|
||||
|
||||
# Disable Flask default logging
|
||||
log = logging.getLogger('werkzeug')
|
||||
@@ -57,6 +57,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(swaparr_bp, url_prefix='/api/swaparr')
|
||||
|
||||
# Register the authentication check to run before requests
|
||||
app.before_request(authenticate_request)
|
||||
|
||||
Reference in New Issue
Block a user