diff --git a/README.md b/README.md
index 045efc8d..19363cc6 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
 |
-
+
**NOTE**: This utilizes Sonarr API Version - `5`. Legacy name of this program: Sonarr Hunter.
**Change Log:**
diff --git a/main.py b/main.py
index e7229b5b..8ec983aa 100644
--- a/main.py
+++ b/main.py
@@ -8,13 +8,27 @@
import sys
import os
import socket
+import signal
+import importlib
from utils.logger import logger
from config import HUNT_MODE, SLEEP_DURATION, MINIMUM_DOWNLOAD_QUEUE_SIZE, ENABLE_WEB_UI, log_configuration, refresh_settings
from missing import process_missing_episodes
-from upgrade import process_cutoff_upgrades
from state import check_state_reset, calculate_reset_time
from api import get_download_queue_size
+# Flag to indicate if cycle should restart
+restart_cycle = False
+
+def signal_handler(signum, frame):
+ """Handle signals from the web UI for cycle restart"""
+ global restart_cycle
+ if signum == signal.SIGUSR1:
+ logger.warning("⚠️ Received restart signal from web UI. Immediately aborting current operations... ⚠️")
+ restart_cycle = True
+
+# Register signal handler for SIGUSR1
+signal.signal(signal.SIGUSR1, signal_handler)
+
def get_ip_address():
"""Get the host's IP address from API_URL for display"""
try:
@@ -39,8 +53,35 @@ def get_ip_address():
except:
return "YOUR_SERVER_IP"
+def force_reload_all_modules():
+ """Force reload of all relevant modules to ensure fresh settings"""
+ try:
+ # Force reload the config module
+ import config
+ importlib.reload(config)
+
+ # Reload any modules that might cache config values
+ import missing
+ importlib.reload(missing)
+
+ import upgrade
+ importlib.reload(upgrade)
+
+ # Call the refresh function to ensure settings are updated
+ config.refresh_settings()
+
+ # Log the reloaded settings for verification
+ logger.warning("⚠️ Settings reloaded from JSON file after restart signal ⚠️")
+ config.log_configuration(logger)
+
+ return True
+ except Exception as e:
+ logger.error(f"Error reloading modules: {e}")
+ return False
+
def main_loop() -> None:
"""Main processing loop for Huntarr-Sonarr"""
+ global restart_cycle
# Log welcome message for web interface
logger.info("=== Huntarr [Sonarr Edition] Starting ===")
@@ -53,8 +94,15 @@ def main_loop() -> None:
logger.info("GitHub: https://github.com/plexguide/huntarr-sonarr")
while True:
- # Refresh settings from the settings manager before each cycle
- refresh_settings()
+ # Set restart_cycle flag to False at the beginning of each cycle
+ restart_cycle = False
+
+ # Always force reload all modules at the start of each cycle
+ force_reload_all_modules()
+
+ # Import after reload to ensure we get fresh values
+ from config import HUNT_MODE, HUNT_MISSING_SHOWS, HUNT_UPGRADE_EPISODES
+ from upgrade import process_cutoff_upgrades
# Check if state files need to be reset
check_state_reset()
@@ -69,13 +117,29 @@ def main_loop() -> None:
if MINIMUM_DOWNLOAD_QUEUE_SIZE < 0 or (MINIMUM_DOWNLOAD_QUEUE_SIZE >= 0 and download_queue_size <= MINIMUM_DOWNLOAD_QUEUE_SIZE):
# Process shows/episodes based on HUNT_MODE
- if HUNT_MODE in ["missing", "both"]:
+ if restart_cycle:
+ logger.warning("⚠️ Restarting cycle due to settings change... ⚠️")
+ continue
+
+ if HUNT_MODE in ["missing", "both"] and HUNT_MISSING_SHOWS > 0:
if process_missing_episodes():
processing_done = True
+
+ # Check if restart signal received
+ if restart_cycle:
+ logger.warning("⚠️ Restarting cycle due to settings change... ⚠️")
+ continue
- if HUNT_MODE in ["upgrade", "both"]:
+ if HUNT_MODE in ["upgrade", "both"] and HUNT_UPGRADE_EPISODES > 0:
+ logger.info(f"Starting upgrade process with HUNT_UPGRADE_EPISODES={HUNT_UPGRADE_EPISODES}")
+
if process_cutoff_upgrades():
processing_done = True
+
+ # Check if restart signal received
+ if restart_cycle:
+ logger.warning("⚠️ Restarting cycle due to settings change... ⚠️")
+ continue
else:
logger.info(f"Download queue size ({download_queue_size}) is above the minimum threshold ({MINIMUM_DOWNLOAD_QUEUE_SIZE}). Skipped processing.")
@@ -101,14 +165,19 @@ def main_loop() -> None:
sleep_start = time.time()
sleep_end = sleep_start + CURRENT_SLEEP_DURATION
- while time.time() < sleep_end:
- # Sleep in smaller chunks for more responsive shutdown
- time.sleep(min(10, sleep_end - time.time()))
+ while time.time() < sleep_end and not restart_cycle:
+ # Sleep in smaller chunks for more responsive shutdown and restart
+ time.sleep(min(1, sleep_end - time.time()))
# Every minute, log the remaining sleep time for web interface visibility
if int((time.time() - sleep_start) % 60) == 0 and time.time() < sleep_end - 10:
remaining = int(sleep_end - time.time())
logger.debug(f"Sleeping... {remaining}s remaining until next cycle")
+
+ # Check if restart signal received
+ if restart_cycle:
+ logger.warning("⚠️ Sleep interrupted due to settings change. Restarting cycle immediately... ⚠️")
+ break
if __name__ == "__main__":
# Log configuration settings
diff --git a/static/css/style.css b/static/css/style.css
index 5c76db5c..818d897d 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -467,6 +467,23 @@ input:checked + .toggle-slider:before {
transform: translateX(26px);
}
+/* Disabled button styles */
+.disabled-button {
+ background-color: #cccccc !important;
+ color: #666666 !important;
+ cursor: not-allowed !important;
+ opacity: 0.7;
+}
+
+/* Adjust the existing save button styles to ensure they work with the disabled state */
+.save-button:hover:not(.disabled-button) {
+ background-color: var(--save-button-hover);
+}
+
+.reset-button:hover:not(.disabled-button) {
+ background-color: var(--reset-button-hover);
+}
+
@media (max-width: 768px) {
.header {
flex-direction: column;
diff --git a/static/js/main.js b/static/js/main.js
index e6b10e0b..224d047b 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -36,6 +36,9 @@ document.addEventListener('DOMContentLoaded', function() {
const saveSettingsBottomButton = document.getElementById('saveSettingsBottom');
const resetSettingsBottomButton = document.getElementById('resetSettingsBottom');
+ // Store original settings values
+ let originalSettings = {};
+
// Update sleep duration display
function updateSleepDurationDisplay() {
const seconds = parseInt(sleepDurationInput.value) || 900;
@@ -59,7 +62,10 @@ document.addEventListener('DOMContentLoaded', function() {
sleepDurationHoursSpan.textContent = displayText;
}
- sleepDurationInput.addEventListener('input', updateSleepDurationDisplay);
+ sleepDurationInput.addEventListener('input', function() {
+ updateSleepDurationDisplay();
+ checkForChanges();
+ });
// Theme management
function loadTheme() {
@@ -126,6 +132,58 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
+ // Function to check if settings have changed from original values
+ function checkForChanges() {
+ if (!originalSettings.huntarr) return; // Don't check if original settings not loaded
+
+ let hasChanges = false;
+
+ // Check Basic Settings
+ if (parseInt(huntMissingShowsInput.value) !== originalSettings.huntarr.hunt_missing_shows) hasChanges = true;
+ if (parseInt(huntUpgradeEpisodesInput.value) !== originalSettings.huntarr.hunt_upgrade_episodes) hasChanges = true;
+ if (parseInt(sleepDurationInput.value) !== originalSettings.huntarr.sleep_duration) hasChanges = true;
+ if (parseInt(stateResetIntervalInput.value) !== originalSettings.huntarr.state_reset_interval_hours) hasChanges = true;
+ if (monitoredOnlyInput.checked !== originalSettings.huntarr.monitored_only) hasChanges = true;
+ if (skipFutureEpisodesInput.checked !== originalSettings.huntarr.skip_future_episodes) hasChanges = true;
+ if (skipSeriesRefreshInput.checked !== originalSettings.huntarr.skip_series_refresh) hasChanges = true;
+
+ // Check Advanced Settings
+ if (parseInt(apiTimeoutInput.value) !== originalSettings.advanced.api_timeout) hasChanges = true;
+ if (debugModeInput.checked !== originalSettings.advanced.debug_mode) hasChanges = true;
+ if (parseInt(commandWaitDelayInput.value) !== originalSettings.advanced.command_wait_delay) hasChanges = true;
+ if (parseInt(commandWaitAttemptsInput.value) !== originalSettings.advanced.command_wait_attempts) hasChanges = true;
+ if (parseInt(minimumDownloadQueueSizeInput.value) !== originalSettings.advanced.minimum_download_queue_size) hasChanges = true;
+ if (randomMissingInput.checked !== originalSettings.advanced.random_missing) hasChanges = true;
+ if (randomUpgradesInput.checked !== originalSettings.advanced.random_upgrades) hasChanges = true;
+
+ // Enable/disable save buttons based on whether there are changes
+ saveSettingsButton.disabled = !hasChanges;
+ saveSettingsBottomButton.disabled = !hasChanges;
+
+ // Apply visual indicator based on disabled state
+ if (hasChanges) {
+ saveSettingsButton.classList.remove('disabled-button');
+ saveSettingsBottomButton.classList.remove('disabled-button');
+ } else {
+ saveSettingsButton.classList.add('disabled-button');
+ saveSettingsBottomButton.classList.add('disabled-button');
+ }
+
+ return hasChanges;
+ }
+
+ // Add change event listeners to all form elements
+ [huntMissingShowsInput, huntUpgradeEpisodesInput, stateResetIntervalInput,
+ apiTimeoutInput, commandWaitDelayInput, commandWaitAttemptsInput,
+ minimumDownloadQueueSizeInput].forEach(input => {
+ input.addEventListener('input', checkForChanges);
+ });
+
+ [monitoredOnlyInput, randomMissingInput, randomUpgradesInput,
+ skipFutureEpisodesInput, skipSeriesRefreshInput, debugModeInput].forEach(checkbox => {
+ checkbox.addEventListener('change', checkForChanges);
+ });
+
// Load settings from API
function loadSettings() {
fetch('/api/settings')
@@ -134,6 +192,9 @@ document.addEventListener('DOMContentLoaded', function() {
const huntarr = data.huntarr || {};
const advanced = data.advanced || {};
+ // Store original settings for comparison
+ originalSettings = JSON.parse(JSON.stringify(data));
+
// Fill form with current settings - Basic settings
huntMissingShowsInput.value = huntarr.hunt_missing_shows !== undefined ? huntarr.hunt_missing_shows : 1;
huntUpgradeEpisodesInput.value = huntarr.hunt_upgrade_episodes !== undefined ? huntarr.hunt_upgrade_episodes : 5;
@@ -154,12 +215,23 @@ document.addEventListener('DOMContentLoaded', function() {
// Handle random settings
randomMissingInput.checked = advanced.random_missing !== false;
randomUpgradesInput.checked = advanced.random_upgrades !== false;
+
+ // Initialize save buttons state
+ saveSettingsButton.disabled = true;
+ saveSettingsBottomButton.disabled = true;
+ saveSettingsButton.classList.add('disabled-button');
+ saveSettingsBottomButton.classList.add('disabled-button');
})
.catch(error => console.error('Error loading settings:', error));
}
// Function to save settings
function saveSettings() {
+ if (!checkForChanges()) {
+ // If no changes, don't do anything
+ return;
+ }
+
const settings = {
huntarr: {
hunt_missing_shows: parseInt(huntMissingShowsInput.value) || 0,
@@ -191,7 +263,21 @@ document.addEventListener('DOMContentLoaded', function() {
.then(response => response.json())
.then(data => {
if (data.success) {
- alert('Settings saved successfully!');
+ // Update original settings after successful save
+ originalSettings = JSON.parse(JSON.stringify(settings));
+
+ // Disable save buttons
+ saveSettingsButton.disabled = true;
+ saveSettingsBottomButton.disabled = true;
+ saveSettingsButton.classList.add('disabled-button');
+ saveSettingsBottomButton.classList.add('disabled-button');
+
+ // Show success message
+ if (data.changes_made) {
+ alert('Settings saved successfully and cycle restarted to apply changes!');
+ } else {
+ alert('No changes detected.');
+ }
} else {
alert('Error saving settings: ' + (data.message || 'Unknown error'));
}
@@ -211,7 +297,7 @@ document.addEventListener('DOMContentLoaded', function() {
.then(response => response.json())
.then(data => {
if (data.success) {
- alert('Settings reset to defaults.');
+ alert('Settings reset to defaults and cycle restarted.');
loadSettings();
} else {
alert('Error resetting settings: ' + (data.message || 'Unknown error'));
diff --git a/upgrade.py b/upgrade.py
index c12be05b..fa30e5e5 100644
--- a/upgrade.py
+++ b/upgrade.py
@@ -7,9 +7,9 @@
import random
import time
import datetime
+import importlib
from utils.logger import logger
from config import (
- HUNT_UPGRADE_EPISODES,
MONITORED_ONLY,
RANDOM_SELECTION,
RANDOM_UPGRADES,
@@ -19,6 +19,13 @@
from api import get_cutoff_unmet, get_cutoff_unmet_total_pages, refresh_series, episode_search_episodes, sonarr_request
from state import load_processed_ids, save_processed_id, truncate_processed_list, PROCESSED_UPGRADE_FILE
+def get_current_upgrade_limit():
+ """Get the current HUNT_UPGRADE_EPISODES value directly from config"""
+ # Force reload the config module to get the latest value
+ import config
+ importlib.reload(config)
+ return config.HUNT_UPGRADE_EPISODES
+
def process_cutoff_upgrades() -> bool:
"""
Process episodes that need quality upgrades (cutoff unmet).
@@ -26,6 +33,9 @@ def process_cutoff_upgrades() -> bool:
Returns:
True if any processing was done, False otherwise
"""
+ # Get the current value directly at the start of processing
+ HUNT_UPGRADE_EPISODES = get_current_upgrade_limit()
+
logger.info("=== Checking for Quality Upgrades (Cutoff Unmet) ===")
# Skip if HUNT_UPGRADE_EPISODES is set to 0
@@ -59,8 +69,12 @@ def process_cutoff_upgrades() -> bool:
logger.info("Using sequential selection for quality upgrades (RANDOM_UPGRADES=false)")
while True:
- if episodes_processed >= HUNT_UPGRADE_EPISODES:
- logger.info(f"Reached HUNT_UPGRADE_EPISODES={HUNT_UPGRADE_EPISODES} for this cycle.")
+ # Check again to make sure we're using the current limit
+ # This ensures if settings changed during processing, we use the new value
+ current_limit = get_current_upgrade_limit()
+
+ if episodes_processed >= current_limit:
+ logger.info(f"Reached HUNT_UPGRADE_EPISODES={current_limit} for this cycle.")
break
# If random selection is enabled, pick a random page each iteration
@@ -92,7 +106,10 @@ def process_cutoff_upgrades() -> bool:
random.shuffle(indices)
for idx in indices:
- if episodes_processed >= HUNT_UPGRADE_EPISODES:
+ # Check again for the current limit in case it was changed during processing
+ current_limit = get_current_upgrade_limit()
+
+ if episodes_processed >= current_limit:
break
ep_obj = episodes[idx]
@@ -165,7 +182,10 @@ def process_cutoff_upgrades() -> bool:
save_processed_id(PROCESSED_UPGRADE_FILE, episode_id)
episodes_processed += 1
processing_done = True
- logger.info(f"Processed {episodes_processed}/{HUNT_UPGRADE_EPISODES} upgrade episodes this cycle.")
+
+ # Log with the current limit, not the initial one
+ current_limit = get_current_upgrade_limit()
+ logger.info(f"Processed {episodes_processed}/{current_limit} upgrade episodes this cycle.")
else:
logger.warning(f"WARNING: Search command failed for episode ID {episode_id}.")
continue
@@ -176,6 +196,8 @@ def process_cutoff_upgrades() -> bool:
# In random mode, we just handle one random page this iteration,
# then check if we've processed enough episodes or continue to another random page
+ # Log with the current limit, not the initial one
+ current_limit = get_current_upgrade_limit()
logger.info(f"Completed processing {episodes_processed} upgrade episodes for this cycle.")
truncate_processed_list(PROCESSED_UPGRADE_FILE)
diff --git a/web_server.py b/web_server.py
index 8459a5ec..0644e558 100644
--- a/web_server.py
+++ b/web_server.py
@@ -10,6 +10,8 @@
import pathlib
import socket
import json
+import signal
+import sys
from flask import Flask, render_template, Response, stream_with_context, request, jsonify, send_from_directory
import logging
from config import ENABLE_WEB_UI
@@ -33,6 +35,24 @@
LOG_DIR = pathlib.Path("/tmp/huntarr-logs")
LOG_DIR.mkdir(parents=True, exist_ok=True)
+# Get the PID of the main process
+def get_main_process_pid():
+ try:
+ # Try to find the main.py process
+ for proc in os.listdir('/proc'):
+ if not proc.isdigit():
+ continue
+ try:
+ with open(f'/proc/{proc}/cmdline', 'r') as f:
+ cmdline = f.read().replace('\0', ' ')
+ if 'python' in cmdline and 'main.py' in cmdline:
+ return int(proc)
+ except (IOError, ProcessLookupError):
+ continue
+ return None
+ except:
+ return None
+
@app.route('/')
def index():
"""Render the main page"""
@@ -76,60 +96,96 @@ def get_settings():
@app.route('/api/settings', methods=['POST'])
def update_settings():
- """Update settings"""
+ """Update settings and restart the main process to apply them immediately"""
try:
data = request.json
if not data:
return jsonify({"success": False, "message": "No data provided"}), 400
- # Log the settings changes
- timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
- changes_log = []
+ # Get current settings to compare
+ old_settings = settings_manager.get_all_settings()
+ old_huntarr = old_settings.get("huntarr", {})
+ old_advanced = old_settings.get("advanced", {})
+ old_ui = old_settings.get("ui", {})
+
+ # Find changes
+ huntarr_changes = {}
+ advanced_changes = {}
+ ui_changes = {}
+
+ # Track if any real changes were made
+ changes_made = False
- # Update huntarr settings
+ # Update huntarr settings and track changes
if "huntarr" in data:
- old_settings = settings_manager.get_setting("huntarr", None, {})
for key, value in data["huntarr"].items():
- old_value = old_settings.get(key)
+ old_value = old_huntarr.get(key)
if old_value != value:
- # Remove the "from Default" text - just log the new value
- changes_log.append(f"Changed {key} to {value}")
+ huntarr_changes[key] = {"old": old_value, "new": value}
+ changes_made = True
settings_manager.update_setting("huntarr", key, value)
- # Update UI settings
+ # Update UI settings and track changes
if "ui" in data:
- old_settings = settings_manager.get_setting("ui", None, {})
for key, value in data["ui"].items():
- old_value = old_settings.get(key)
+ old_value = old_ui.get(key)
if old_value != value:
- # Remove the "from Default" text - just log the new value
- changes_log.append(f"Changed UI.{key} to {value}")
+ ui_changes[key] = {"old": old_value, "new": value}
+ changes_made = True
settings_manager.update_setting("ui", key, value)
- # Update advanced settings
+ # Update advanced settings and track changes
if "advanced" in data:
- old_settings = settings_manager.get_setting("advanced", None, {})
for key, value in data["advanced"].items():
- old_value = old_settings.get(key)
+ old_value = old_advanced.get(key)
if old_value != value:
- changes_log.append(f"Changed advanced.{key} to {value}")
+ advanced_changes[key] = {"old": old_value, "new": value}
+ changes_made = True
settings_manager.update_setting("advanced", key, value)
# Special handling for debug_mode setting
if key == "debug_mode" and old_value != value:
# Reconfigure the logger with new debug mode setting
setup_logger(value)
- changes_log.append(f"Reconfigured logger with DEBUG_MODE={value}")
- # Write changes to log file
- if changes_log:
+ # Log changes if any were made
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ if changes_made:
with open(LOG_FILE, 'a') as f:
f.write(f"{timestamp} - huntarr-web - INFO - Settings updated by user\n")
- for change in changes_log:
- f.write(f"{timestamp} - huntarr-web - INFO - {change}\n")
+
+ # Log huntarr changes
+ for key, change in huntarr_changes.items():
+ f.write(f"{timestamp} - huntarr-web - INFO - Changed {key} from {change['old']} to {change['new']}\n")
+
+ # Log advanced changes
+ for key, change in advanced_changes.items():
+ f.write(f"{timestamp} - huntarr-web - INFO - Changed advanced.{key} from {change['old']} to {change['new']}\n")
+
+ # Log UI changes
+ for key, change in ui_changes.items():
+ f.write(f"{timestamp} - huntarr-web - INFO - Changed UI.{key} from {change['old']} to {change['new']}\n")
+
f.write(f"{timestamp} - huntarr-web - INFO - Settings saved successfully\n")
-
- return jsonify({"success": True})
+ f.write(f"{timestamp} - huntarr-web - INFO - Restarting current cycle to apply new settings immediately\n")
+
+ # Try to signal the main process to restart the cycle
+ main_pid = get_main_process_pid()
+ if main_pid:
+ try:
+ # Send a SIGUSR1 signal which we'll handle in main.py to restart the cycle
+ os.kill(main_pid, signal.SIGUSR1)
+ return jsonify({"success": True, "message": "Settings saved and cycle restarted", "changes_made": True})
+ except:
+ # If signaling fails, just return success for the settings save
+ return jsonify({"success": True, "message": "Settings saved, but cycle not restarted", "changes_made": True})
+ else:
+ return jsonify({"success": True, "message": "Settings saved, but main process not found", "changes_made": True})
+ else:
+ # No changes were made
+ return jsonify({"success": True, "message": "No changes detected", "changes_made": False})
+
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@@ -137,14 +193,31 @@ def update_settings():
def reset_settings():
"""Reset settings to defaults"""
try:
+ # Get current settings to compare
+ old_settings = settings_manager.get_all_settings()
+
+ # Reset settings
settings_manager.save_settings(settings_manager.DEFAULT_SETTINGS)
# Log the reset
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(LOG_FILE, 'a') as f:
f.write(f"{timestamp} - huntarr-web - INFO - Settings reset to defaults by user\n")
+ f.write(f"{timestamp} - huntarr-web - INFO - Restarting current cycle to apply new settings immediately\n")
+
+ # Try to signal the main process to restart the cycle
+ main_pid = get_main_process_pid()
+ if main_pid:
+ try:
+ # Send a SIGUSR1 signal which we'll handle in main.py to restart the cycle
+ os.kill(main_pid, signal.SIGUSR1)
+ return jsonify({"success": True, "message": "Settings reset and cycle restarted"})
+ except:
+ # If signaling fails, just return success for the settings reset
+ return jsonify({"success": True, "message": "Settings reset, but cycle not restarted"})
+ else:
+ return jsonify({"success": True, "message": "Settings reset, but main process not found"})
- return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500