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