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:
Admin9705
2025-04-30 08:05:19 -04:00
parent 86f53763be
commit 11280a84bb
12 changed files with 854 additions and 7 deletions

View File

@@ -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)

View File

@@ -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 = `

View File

@@ -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>

View File

@@ -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
View 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)

View 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"]

View 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')}")

View 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')

View File

@@ -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

View 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
}

View File

@@ -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}}

View File

@@ -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)