Files
Huntarr-Sonarr/main.py
Admin9705 0fee673acb Add health check and graceful shutdown support
- Implemented health check endpoint for Docker and orchestration systems.
- Added graceful shutdown configuration in Docker Compose and application code.
- Enhanced shutdown handling in main application and background tasks for improved diagnostics.
- Updated Dockerfile to include health check command.
- Introduced readiness check endpoint for Kubernetes-style orchestration.
2025-06-22 20:39:19 -04:00

558 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Main entry point for Huntarr
Starts both the web server and the background processing tasks.
"""
import os
import threading
import sys
import signal
import logging # Use standard logging for initial setup
import atexit
import time
import time
# Import path configuration early to set up environment
try:
from src.primary.utils import config_paths
print(f"Using config directory: {config_paths.CONFIG_DIR}")
except Exception as e:
print(f"Warning: Failed to initialize config paths: {str(e)}")
# Continue anyway - we'll handle this later
# Ensure the 'src' directory is in the Python path
# This allows importing modules from 'src.primary' etc.
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'src')))
# --- Early Logging Setup (Before importing app components) ---
# Basic logging to capture early errors during import or setup
log_level = logging.DEBUG if os.environ.get('DEBUG', 'false').lower() == 'true' else logging.INFO
# Create a custom formatter that uses local time
class LocalTimeFormatter(logging.Formatter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.converter = time.localtime # Use local time instead of UTC
# Disable basic logging to prevent duplicates - we use custom loggers
# logging.basicConfig(level=log_level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
# Apply local time converter to all existing handlers
for handler in logging.root.handlers:
if hasattr(handler, 'formatter') and handler.formatter:
handler.formatter.converter = time.localtime
root_logger = logging.getLogger("HuntarrRoot") # Specific logger for this entry point
root_logger.info("--- Huntarr Main Process Starting ---")
root_logger.info(f"Python sys.path: {sys.path}")
# Check for Windows service commands
if sys.platform == 'win32' and len(sys.argv) > 1:
if sys.argv[1] == '--install-service':
try:
from src.primary.windows_service import install_service
success = install_service()
sys.exit(0 if success else 1)
except ImportError:
root_logger.error("Failed to import Windows service module. Make sure pywin32 is installed.")
sys.exit(1)
except Exception as e:
root_logger.exception(f"Error installing Windows service: {e}")
sys.exit(1)
elif sys.argv[1] == '--remove-service':
try:
from src.primary.windows_service import remove_service
success = remove_service()
sys.exit(0 if success else 1)
except ImportError:
root_logger.error("Failed to import Windows service module. Make sure pywin32 is installed.")
sys.exit(1)
except Exception as e:
root_logger.exception(f"Error removing Windows service: {e}")
sys.exit(1)
elif sys.argv[1] in ['--start', '--stop', '--restart', '--debug', '--update']:
try:
import win32serviceutil
service_name = "Huntarr"
if sys.argv[1] == '--start':
win32serviceutil.StartService(service_name)
print(f"Started {service_name} service")
elif sys.argv[1] == '--stop':
win32serviceutil.StopService(service_name)
print(f"Stopped {service_name} service")
elif sys.argv[1] == '--restart':
win32serviceutil.RestartService(service_name)
print(f"Restarted {service_name} service")
elif sys.argv[1] == '--debug':
# Run the service in debug mode directly
from src.primary.windows_service import HuntarrService
win32serviceutil.HandleCommandLine(HuntarrService)
elif sys.argv[1] == '--update':
# Update the service
win32serviceutil.StopService(service_name)
from src.primary.windows_service import install_service
install_service()
win32serviceutil.StartService(service_name)
print(f"Updated {service_name} service")
sys.exit(0)
except ImportError:
root_logger.error("Failed to import Windows service module. Make sure pywin32 is installed.")
sys.exit(1)
except Exception as e:
root_logger.exception(f"Error managing Windows service: {e}")
sys.exit(1)
try:
# Import the Flask app instance
from primary.web_server import app
# Import the background task starter function and shutdown helpers from the renamed file
from primary.background import start_huntarr, stop_event, shutdown_threads
# Configure logging first
import logging
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
from primary.utils.logger import setup_main_logger, get_logger
from primary.utils.clean_logger import setup_clean_logging
# Initialize main logger
huntarr_logger = setup_main_logger()
# Initialize timezone from TZ environment variable
try:
from primary.settings_manager import initialize_timezone_from_env
initialize_timezone_from_env()
huntarr_logger.info("Timezone initialization completed.")
except Exception as e:
huntarr_logger.warning(f"Failed to initialize timezone from environment: {e}")
# Initialize clean logging for frontend consumption
setup_clean_logging()
huntarr_logger.info("Clean logging system initialized for frontend consumption.")
huntarr_logger.info("Successfully imported application components.")
# Main function startup message removed to reduce log spam
except ImportError as e:
root_logger.critical(f"Fatal Error: Failed to import application components: {e}", exc_info=True)
root_logger.critical("Please ensure the application structure is correct, dependencies are installed (`pip install -r requirements.txt`), and the script is run from the project root.")
sys.exit(1)
except Exception as e:
root_logger.critical(f"Fatal Error: An unexpected error occurred during initial imports: {e}", exc_info=True)
sys.exit(1)
# Global variables for server management
waitress_server = None
shutdown_requested = threading.Event()
# Global shutdown flag for health checks
_global_shutdown_flag = False
def is_shutting_down():
"""Check if the application is shutting down"""
global _global_shutdown_flag
return _global_shutdown_flag or shutdown_requested.is_set() or stop_event.is_set()
def refresh_sponsors_on_startup():
"""Refresh sponsors database from manifest.json on startup"""
import os
import json
try:
# Get database instance
from src.primary.utils.database import get_database
db = get_database()
# Path to manifest.json
manifest_path = os.path.join(os.path.dirname(__file__), 'manifest.json')
if os.path.exists(manifest_path):
with open(manifest_path, 'r') as f:
manifest_data = json.load(f)
sponsors_list = manifest_data.get('sponsors', [])
if sponsors_list:
# Clear existing sponsors and save new ones
db.save_sponsors(sponsors_list)
huntarr_logger.debug(f"Refreshed {len(sponsors_list)} sponsors from manifest.json")
else:
huntarr_logger.warning("No sponsors found in manifest.json")
else:
huntarr_logger.warning(f"manifest.json not found at {manifest_path}")
except Exception as e:
huntarr_logger.error(f"Error refreshing sponsors on startup: {e}")
raise
def load_version_to_database():
"""Load current version from version.txt into database on startup"""
import os
try:
# Get database instance
from src.primary.utils.database import get_database
db = get_database()
# Path to version.txt
version_path = os.path.join(os.path.dirname(__file__), 'version.txt')
if os.path.exists(version_path):
with open(version_path, 'r') as f:
version = f.read().strip()
if version:
# Store version in database
db.set_version(version)
huntarr_logger.info(f"Version {version} loaded into database")
else:
huntarr_logger.warning("version.txt is empty")
else:
huntarr_logger.warning(f"version.txt not found at {version_path}")
except Exception as e:
huntarr_logger.error(f"Error loading version to database: {e}")
# Don't raise - this is not critical enough to stop startup
def run_background_tasks():
"""Runs the Huntarr background processing."""
bg_logger = get_logger("HuntarrBackground") # Use app's logger
try:
bg_logger.info("Starting Huntarr background tasks...")
start_huntarr() # This function contains the main loop and shutdown logic
except Exception as e:
bg_logger.exception(f"Critical error in Huntarr background tasks: {e}")
finally:
bg_logger.info("Huntarr background tasks stopped.")
def run_web_server():
"""Runs the Flask web server using Waitress in production."""
global waitress_server
web_logger = get_logger("WebServer") # Use app's logger
debug_mode = os.environ.get('DEBUG', 'false').lower() == 'true'
host = os.environ.get('FLASK_HOST', '0.0.0.0')
port = int(os.environ.get('HUNTARR_PORT', os.environ.get('PORT', 9705))) # Check HUNTARR_PORT first, then PORT, then default
web_logger.info(f"Starting web server on {host}:{port} (Debug: {debug_mode})...")
# Log the current authentication mode once at startup
try:
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
from primary.settings_manager import load_settings
settings = load_settings("general")
local_access_bypass = settings.get("local_access_bypass", False)
proxy_auth_bypass = settings.get("proxy_auth_bypass", False)
if proxy_auth_bypass:
web_logger.info("🔓 Authentication Mode: NO LOGIN MODE (Proxy authentication bypass enabled)")
elif local_access_bypass:
web_logger.info("🏠 Authentication Mode: LOCAL ACCESS BYPASS (Local network authentication bypass enabled)")
else:
web_logger.info("🔐 Authentication Mode: STANDARD (Full authentication required)")
except Exception as e:
web_logger.warning(f"Could not determine authentication mode at startup: {e}")
if debug_mode:
# Use Flask's development server for debugging (less efficient, auto-reloads)
# Note: use_reloader=True can cause issues with threads starting twice.
web_logger.warning("Running in DEBUG mode with Flask development server.")
try:
app.run(host=host, port=port, debug=True, use_reloader=False)
except Exception as e:
web_logger.exception(f"Flask development server failed: {e}")
# Signal background thread to stop if server fails critically
if not stop_event.is_set():
stop_event.set()
else:
# Use Waitress for production with proper signal handling
try:
from waitress import serve
from waitress.server import create_server
import time
web_logger.info("Running with Waitress production server.")
# Create the server instance so we can shut it down gracefully
waitress_server = create_server(app, host=host, port=port, threads=8)
web_logger.info("Waitress server starting...")
# Start the server in a separate thread
server_thread = threading.Thread(target=waitress_server.run, daemon=True)
server_thread.start()
# Monitor for shutdown signal in the main thread
while not shutdown_requested.is_set() and not stop_event.is_set():
try:
# Check both shutdown events
if shutdown_requested.wait(timeout=0.5) or stop_event.wait(timeout=0.5):
break
except KeyboardInterrupt:
break
# Shutdown sequence
web_logger.info("Shutdown signal received. Stopping Waitress server...")
try:
waitress_server.close()
web_logger.info("Waitress server close() called.")
# Wait for server thread to finish
server_thread.join(timeout=3.0)
if server_thread.is_alive():
web_logger.warning("Server thread did not stop within timeout.")
else:
web_logger.info("Server thread stopped successfully.")
except Exception as e:
web_logger.exception(f"Error during Waitress server shutdown: {e}")
web_logger.info("Waitress server has stopped.")
except ImportError:
web_logger.error("Waitress not found. Falling back to Flask development server (NOT recommended for production).")
web_logger.error("Install waitress ('pip install waitress') for production use.")
try:
app.run(host=host, port=port, debug=False, use_reloader=False)
except Exception as e:
web_logger.exception(f"Flask development server (fallback) failed: {e}")
# Signal background thread to stop if server fails critically
if not stop_event.is_set():
stop_event.set()
except Exception as e:
web_logger.exception(f"Waitress server failed: {e}")
# Signal background thread to stop if server fails critically
if not stop_event.is_set():
stop_event.set()
def main_shutdown_handler(signum, frame):
"""Gracefully shut down the application."""
global _global_shutdown_flag
_global_shutdown_flag = True # Set global shutdown flag immediately
signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM" if signum == signal.SIGTERM else f"Signal {signum}"
huntarr_logger.info(f"Received {signal_name}. Initiating graceful shutdown...")
# Set a reasonable timeout for shutdown operations
shutdown_start_time = time.time()
shutdown_timeout = 30 # 30 seconds total shutdown timeout
# Immediate database checkpoint to prevent corruption
try:
from primary.utils.database import get_database, get_logs_database
huntarr_logger.info("Performing emergency database checkpoint...")
# Emergency checkpoint for main database
try:
main_db = get_database()
with main_db.get_connection() as conn:
conn.execute("PRAGMA wal_checkpoint(RESTART)")
huntarr_logger.info("Main database emergency checkpoint completed")
except Exception as e:
huntarr_logger.warning(f"Main database emergency checkpoint failed: {e}")
# Emergency checkpoint for logs database
try:
logs_db = get_logs_database()
with logs_db.get_logs_connection() as conn:
conn.execute("PRAGMA wal_checkpoint(RESTART)")
huntarr_logger.info("Logs database emergency checkpoint completed")
except Exception as e:
huntarr_logger.warning(f"Logs database emergency checkpoint failed: {e}")
except Exception as e:
huntarr_logger.warning(f"Emergency database checkpoint failed: {e}")
# Set both shutdown events
if not stop_event.is_set():
stop_event.set()
if not shutdown_requested.is_set():
shutdown_requested.set()
# Also shutdown the Waitress server directly if it exists
global waitress_server
if waitress_server:
try:
huntarr_logger.info("Signaling Waitress server to shutdown...")
waitress_server.close()
except Exception as e:
huntarr_logger.warning(f"Error closing Waitress server: {e}")
# Force exit if shutdown takes too long (Docker container update scenario)
elapsed_time = time.time() - shutdown_start_time
if elapsed_time > shutdown_timeout:
huntarr_logger.warning(f"Shutdown timeout exceeded ({shutdown_timeout}s). Forcing exit with code 0.")
os._exit(0) # Clean exit for Docker updates
def cleanup_handler():
"""Cleanup function called at exit"""
cleanup_start_time = time.time()
huntarr_logger.info("Exit cleanup handler called")
# Shutdown databases gracefully with timeout
try:
from primary.utils.database import get_database, get_logs_database
# Close main database connections
main_db = get_database()
if hasattr(main_db, '_database_instance') and main_db._database_instance is not None:
huntarr_logger.info("Closing main database connections...")
# Force close any open connections
try:
with main_db.get_connection() as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") # Flush WAL to main database
# Skip VACUUM for faster shutdown during updates
huntarr_logger.debug("Main database WAL checkpoint completed")
except Exception as db_error:
huntarr_logger.warning(f"Error during main database cleanup: {db_error}")
# Close logs database connections
logs_db = get_logs_database()
if hasattr(logs_db, '_logs_database_instance') and logs_db._logs_database_instance is not None:
huntarr_logger.info("Closing logs database connections...")
try:
with logs_db.get_logs_connection() as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") # Flush WAL to logs database
# Skip VACUUM for faster shutdown during updates
huntarr_logger.debug("Logs database WAL checkpoint completed")
except Exception as logs_error:
huntarr_logger.warning(f"Error during logs database cleanup: {logs_error}")
huntarr_logger.info("Database shutdown completed")
except Exception as e:
huntarr_logger.warning(f"Error during database shutdown: {e}")
# Ensure stop events are set
if not stop_event.is_set():
stop_event.set()
if not shutdown_requested.is_set():
shutdown_requested.set()
# Log cleanup timing for Docker update diagnostics
cleanup_duration = time.time() - cleanup_start_time
huntarr_logger.info(f"Cleanup completed in {cleanup_duration:.2f} seconds")
def main():
"""Main entry point function for Huntarr application.
This function is called by app_launcher.py in the packaged ARM application.
"""
# Register signal handlers for graceful shutdown in the main process
signal.signal(signal.SIGINT, main_shutdown_handler)
signal.signal(signal.SIGTERM, main_shutdown_handler)
# Register cleanup handler
atexit.register(cleanup_handler)
# Initialize databases with default configurations
try:
from primary.settings_manager import initialize_database
initialize_database()
huntarr_logger.info("Main database initialization completed successfully")
# Initialize base URL from BASE_URL environment variable early
# This needs to happen before web server initialization
try:
from primary.settings_manager import initialize_base_url_from_env
initialize_base_url_from_env()
huntarr_logger.info("Base URL initialization completed.")
# Reconfigure the web server with the updated base URL
from primary.web_server import reconfigure_base_url
reconfigure_base_url()
huntarr_logger.info("Web server reconfigured with updated base URL.")
except Exception as e:
huntarr_logger.warning(f"Failed to initialize base URL from environment: {e}")
# Initialize database logging system (now uses main huntarr.db)
try:
from primary.utils.database import get_logs_database, schedule_log_cleanup
logs_db = get_logs_database()
schedule_log_cleanup()
huntarr_logger.info("Database logging system initialized with scheduled cleanup.")
except Exception as e:
huntarr_logger.warning(f"Failed to initialize database logging: {e}")
# Load version from version.txt into database on startup
try:
load_version_to_database()
except Exception as version_error:
huntarr_logger.warning(f"Failed to load version to database: {version_error}")
# Refresh sponsors from manifest.json on startup
try:
refresh_sponsors_on_startup()
huntarr_logger.info("Sponsors database refreshed from manifest.json")
except Exception as sponsor_error:
huntarr_logger.warning(f"Failed to refresh sponsors on startup: {sponsor_error}")
except Exception as e:
huntarr_logger.error(f"Failed to initialize databases: {e}")
huntarr_logger.error("Application may not function correctly without database")
# Continue anyway - the app might still work with defaults
background_thread = None
try:
# Start background tasks in a daemon thread
# Daemon threads exit automatically if the main thread exits unexpectedly,
# but we'll try to join() them for a graceful shutdown.
background_thread = threading.Thread(target=run_background_tasks, name="HuntarrBackground", daemon=True)
background_thread.start()
# Start the web server in the main thread (blocking)
# This will run until the server is stopped (e.g., by Ctrl+C)
run_web_server()
except KeyboardInterrupt:
huntarr_logger.info("KeyboardInterrupt received in main thread. Shutting down...")
if not stop_event.is_set():
stop_event.set()
if not shutdown_requested.is_set():
shutdown_requested.set()
except Exception as e:
huntarr_logger.exception(f"An unexpected error occurred in the main execution block: {e}")
if not stop_event.is_set():
stop_event.set() # Ensure shutdown is triggered on unexpected errors
if not shutdown_requested.is_set():
shutdown_requested.set()
finally:
# --- Cleanup ---
huntarr_logger.info("Web server has stopped. Initiating final shutdown sequence...")
# Ensure the stop event is set (might already be set by signal handler or error)
if not stop_event.is_set():
huntarr_logger.warning("Stop event was not set before final cleanup. Setting now.")
stop_event.set()
# Wait for the background thread to finish cleanly
if background_thread and background_thread.is_alive():
huntarr_logger.info("Waiting for background tasks to complete...")
background_thread.join(timeout=5) # Reduced timeout for faster shutdown
if background_thread.is_alive():
huntarr_logger.warning("Background thread did not stop gracefully within the timeout.")
elif background_thread:
huntarr_logger.info("Background thread already stopped.")
else:
huntarr_logger.info("Background thread was not started.")
# Call the shutdown_threads function from primary.main (if it does more than just join)
# This might be redundant if start_huntarr handles its own cleanup via stop_event
# huntarr_logger.info("Calling shutdown_threads()...")
# shutdown_threads() # Uncomment if primary.main.shutdown_threads() does more cleanup
huntarr_logger.info("--- Huntarr Main Process Exiting ---")
# Return appropriate exit code based on shutdown reason
if shutdown_requested.is_set() or stop_event.is_set():
huntarr_logger.info("Clean shutdown completed - Exit code 0")
return 0 # Clean shutdown
else:
huntarr_logger.warning("Unexpected shutdown - Exit code 1")
return 1 # Unexpected shutdown
if __name__ == '__main__':
# Call the main function and exit with its return code
# This will use the return value from main() (0 for success) as the exit code
sys.exit(main())