diff --git a/Dockerfile b/Dockerfile index 674fb254..c710246f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,22 +6,21 @@ RUN pip install --no-cache-dir -r requirements.txt # Install Flask for the web interface RUN pip install --no-cache-dir flask # Copy application files -COPY main.py config.py api.py state.py ./ -COPY missing.py upgrade.py ./ -COPY web_server.py ./ +COPY *.py ./ COPY utils/ ./utils/ +COPY web_server.py ./ # Create templates directory and copy index.html -RUN mkdir -p templates +RUN mkdir -p templates static/css static/js COPY templates/ ./templates/ +COPY static/ ./static/ # Create required directories -RUN mkdir -p /tmp/huntarr-state -RUN mkdir -p /tmp/huntarr-logs +RUN mkdir -p /config/stateful /config/settings # Default environment variables ENV API_KEY="your-api-key" \ API_URL="http://your-sonarr-address:8989" \ API_TIMEOUT="60" \ HUNT_MISSING_SHOWS=1 \ -HUNT_UPGRADE_EPISODES=0 \ +HUNT_UPGRADE_EPISODES=5 \ SLEEP_DURATION=900 \ STATE_RESET_INTERVAL_HOURS=168 \ RANDOM_SELECTION="true" \ @@ -30,6 +29,8 @@ DEBUG_MODE="false" \ ENABLE_WEB_UI="true" \ SKIP_FUTURE_EPISODES="true" \ SKIP_SERIES_REFRESH="false" +# Create volume mount points +VOLUME ["/config"] # Expose web interface port EXPOSE 8988 # Add startup script that conditionally starts the web UI diff --git a/README.md b/README.md index d492cff8..fdd360ac 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ The following environment variables can be configured: - The minimum number of items in the download queue before a new hunt is initiated. For example if set to `5` then a new hunt will only start when there are 5 or less items marked as `downloading` in the queue. - This helps prevent overwhelming the queue with too many download requests at once and avoids creating a massive backlog of downloads. - Set to `-1` to disable this check. - + ## Web Interface Huntarr-Sonarr includes a real-time log viewer web interface that allows you to monitor its operation directly from your browser. diff --git a/config.py b/config.py index e597e4ad..3a3067dd 100644 --- a/config.py +++ b/config.py @@ -6,6 +6,7 @@ import os import logging +import settings_manager # Web UI Configuration ENABLE_WEB_UI = os.environ.get("ENABLE_WEB_UI", "true").lower() == "true" @@ -21,6 +22,9 @@ API_TIMEOUT = 60 print(f"Warning: Invalid API_TIMEOUT value, using default: {API_TIMEOUT}") +# Settings that can be overridden by the settings manager +# Load from environment first, will be overridden by settings if they exist + # Missing Content Settings try: HUNT_MISSING_SHOWS = int(os.environ.get("HUNT_MISSING_SHOWS", "1")) @@ -49,6 +53,14 @@ STATE_RESET_INTERVAL_HOURS = 168 print(f"Warning: Invalid STATE_RESET_INTERVAL_HOURS value, using default: {STATE_RESET_INTERVAL_HOURS}") +# Selection Settings +RANDOM_SELECTION = os.environ.get("RANDOM_SELECTION", "true").lower() == "true" +MONITORED_ONLY = os.environ.get("MONITORED_ONLY", "true").lower() == "true" + +# New Options +SKIP_FUTURE_EPISODES = os.environ.get("SKIP_FUTURE_EPISODES", "true").lower() == "true" +SKIP_SERIES_REFRESH = os.environ.get("SKIP_SERIES_REFRESH", "false").lower() == "true" + # Delay in seconds between checking the status of a command (default 1 second) try: COMMAND_WAIT_DELAY = int(os.environ.get("COMMAND_WAIT_DELAY", "1")) @@ -70,26 +82,34 @@ MINIMUM_DOWNLOAD_QUEUE_SIZE = -1 print(f"Warning: Invalid MINIMUM_DOWNLOAD_QUEUE_SIZE value, using default: {MINIMUM_DOWNLOAD_QUEUE_SIZE}") -# New Options - -# Skip processing episodes with air dates in the future (default true) -SKIP_FUTURE_EPISODES = os.environ.get("SKIP_FUTURE_EPISODES", "true").lower() == "true" - -# Skip refreshing series metadata before processing (default false) -SKIP_SERIES_REFRESH = os.environ.get("SKIP_SERIES_REFRESH", "false").lower() == "true" - -# Selection Settings -RANDOM_SELECTION = os.environ.get("RANDOM_SELECTION", "true").lower() == "true" -MONITORED_ONLY = os.environ.get("MONITORED_ONLY", "true").lower() == "true" - # Hunt mode: "missing", "upgrade", or "both" HUNT_MODE = os.environ.get("HUNT_MODE", "both") # Debug Settings DEBUG_MODE = os.environ.get("DEBUG_MODE", "false").lower() == "true" +# Override settings from settings manager if they exist +def refresh_settings(): + """Refresh configuration settings from the settings manager.""" + global HUNT_MISSING_SHOWS, HUNT_UPGRADE_EPISODES, SLEEP_DURATION + global STATE_RESET_INTERVAL_HOURS, MONITORED_ONLY, RANDOM_SELECTION + global SKIP_FUTURE_EPISODES, SKIP_SERIES_REFRESH + + # Load settings from settings manager + HUNT_MISSING_SHOWS = settings_manager.get_setting("huntarr", "hunt_missing_shows", HUNT_MISSING_SHOWS) + HUNT_UPGRADE_EPISODES = settings_manager.get_setting("huntarr", "hunt_upgrade_episodes", HUNT_UPGRADE_EPISODES) + SLEEP_DURATION = settings_manager.get_setting("huntarr", "sleep_duration", SLEEP_DURATION) + STATE_RESET_INTERVAL_HOURS = settings_manager.get_setting("huntarr", "state_reset_interval_hours", STATE_RESET_INTERVAL_HOURS) + MONITORED_ONLY = settings_manager.get_setting("huntarr", "monitored_only", MONITORED_ONLY) + RANDOM_SELECTION = settings_manager.get_setting("huntarr", "random_selection", RANDOM_SELECTION) + SKIP_FUTURE_EPISODES = settings_manager.get_setting("huntarr", "skip_future_episodes", SKIP_FUTURE_EPISODES) + SKIP_SERIES_REFRESH = settings_manager.get_setting("huntarr", "skip_series_refresh", SKIP_SERIES_REFRESH) + def log_configuration(logger): """Log the current configuration settings""" + # Refresh settings from the settings manager + refresh_settings() + logger.info("=== Huntarr [Sonarr Edition] Starting ===") logger.info(f"API URL: {API_URL}") logger.info(f"API Timeout: {API_TIMEOUT}s") @@ -102,4 +122,7 @@ def log_configuration(logger): logger.info(f"COMMAND_WAIT_DELAY={COMMAND_WAIT_DELAY}, COMMAND_WAIT_ATTEMPTS={COMMAND_WAIT_ATTEMPTS}") logger.info(f"SKIP_FUTURE_EPISODES={SKIP_FUTURE_EPISODES}, SKIP_SERIES_REFRESH={SKIP_SERIES_REFRESH}") logger.info(f"ENABLE_WEB_UI={ENABLE_WEB_UI}") - logger.debug(f"API_KEY={API_KEY}") \ No newline at end of file + logger.debug(f"API_KEY={API_KEY}") + +# Initial refresh of settings +refresh_settings() \ No newline at end of file diff --git a/main.py b/main.py index 7545ec49..4f94f183 100644 --- a/main.py +++ b/main.py @@ -9,7 +9,7 @@ import os import socket from utils.logger import logger -from config import HUNT_MODE, SLEEP_DURATION, MINIMUM_DOWNLOAD_QUEUE_SIZE, ENABLE_WEB_UI, log_configuration +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 @@ -40,6 +40,9 @@ 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() + # Check if state files need to be reset check_state_reset() diff --git a/settings_manager.py b/settings_manager.py new file mode 100644 index 00000000..dfb9a435 --- /dev/null +++ b/settings_manager.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Settings manager for Huntarr-Sonarr +Handles loading, saving, and providing settings from a JSON file +""" + +import os +import json +import pathlib +import logging +from typing import Dict, Any, Optional + +# Create a simple logger for settings_manager +logging.basicConfig(level=logging.INFO) +settings_logger = logging.getLogger("settings_manager") + +# Settings directory setup +SETTINGS_DIR = pathlib.Path("/config/settings") +SETTINGS_DIR.mkdir(parents=True, exist_ok=True) + +SETTINGS_FILE = SETTINGS_DIR / "huntarr.json" + +# Default settings +DEFAULT_SETTINGS = { + "ui": { + "dark_mode": True + }, + "huntarr": { + "sleep_duration": 900, # 15 minutes in seconds + "hunt_missing_shows": 1, + "hunt_upgrade_episodes": 5, + "state_reset_interval_hours": 168, # 1 week in hours + "monitored_only": True, + "random_selection": True, + "skip_future_episodes": True, + "skip_series_refresh": False + } +} + +def load_settings() -> Dict[str, Any]: + """Load settings from the settings file, or return defaults if not available.""" + try: + if SETTINGS_FILE.exists(): + with open(SETTINGS_FILE, 'r') as f: + settings = json.load(f) + settings_logger.info("Settings loaded from configuration file") + return settings + else: + settings_logger.info("No settings file found, creating with default values") + save_settings(DEFAULT_SETTINGS) + return DEFAULT_SETTINGS + except Exception as e: + settings_logger.error(f"Error loading settings: {e}") + settings_logger.info("Using default settings due to error") + return DEFAULT_SETTINGS + +def save_settings(settings: Dict[str, Any]) -> bool: + """Save settings to the settings file.""" + try: + with open(SETTINGS_FILE, 'w') as f: + json.dump(settings, f, indent=2) + settings_logger.info("Settings saved successfully") + return True + except Exception as e: + settings_logger.error(f"Error saving settings: {e}") + return False + +def update_setting(category: str, key: str, value: Any) -> bool: + """Update a specific setting value.""" + try: + settings = load_settings() + + # Ensure category exists + if category not in settings: + settings[category] = {} + + # Update the value + settings[category][key] = value + + # Save the updated settings + return save_settings(settings) + except Exception as e: + settings_logger.error(f"Error updating setting {category}.{key}: {e}") + return False + +def get_setting(category: str, key: str, default: Any = None) -> Any: + """Get a specific setting value.""" + try: + settings = load_settings() + return settings.get(category, {}).get(key, default) + except Exception as e: + settings_logger.error(f"Error getting setting {category}.{key}: {e}") + return default + +def get_all_settings() -> Dict[str, Any]: + """Get all settings.""" + return load_settings() + +# Initialize settings file if it doesn't exist +if not SETTINGS_FILE.exists(): + save_settings(DEFAULT_SETTINGS) \ No newline at end of file diff --git a/start.sh b/start.sh index 4da6ea4b..70e8503f 100644 --- a/start.sh +++ b/start.sh @@ -1,6 +1,10 @@ #!/bin/sh # Startup script for Huntarr-Sonarr that conditionally starts the web UI +# Ensure the configuration directories exist and have proper permissions +mkdir -p /config/settings /config/stateful +chmod -R 755 /config + # Convert to lowercase ENABLE_WEB_UI=$(echo "${ENABLE_WEB_UI:-true}" | tr '[:upper:]' '[:lower:]') diff --git a/state.py b/state.py index adf5a866..febe7e6b 100644 --- a/state.py +++ b/state.py @@ -12,7 +12,7 @@ from config import STATE_RESET_INTERVAL_HOURS # State directory setup -STATE_DIR = pathlib.Path("/tmp/huntarr-state") +STATE_DIR = pathlib.Path("/config/stateful") STATE_DIR.mkdir(parents=True, exist_ok=True) PROCESSED_MISSING_FILE = STATE_DIR / "processed_missing_ids.txt" diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 00000000..09be8c41 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,484 @@ +:root { + --background-color: #f5f5f5; + --text-color: #333; + --header-bg: #2c3e50; + --header-text: #ecf0f1; + --button-bg: #3498db; + --button-text: #fff; + --button-hover: #2980b9; + --log-background: #fff; + --log-border: #e0e0e0; + --log-text: #333; + --info-color: #2980b9; + --warning-color: #f39c12; + --error-color: #e74c3c; + --debug-color: #7f8c8d; + --switch-bg: #ccc; + --switch-on: #3498db; + --container-border: #ddd; + --input-border: #ccc; + --input-bg: #fff; + --settings-bg: #f9f9f9; + --settings-border: #e0e0e0; + --save-button-bg: #27ae60; + --save-button-hover: #219955; + --reset-button-bg: #e74c3c; + --reset-button-hover: #c0392b; + --donation-banner-bg: #f8f9fa; + --donation-banner-border: #dee2e6; +} + +.dark-theme { + --background-color: #2c3e50; + --text-color: #ecf0f1; + --header-bg: #1a2530; + --header-text: #ecf0f1; + --button-bg: #3498db; + --button-text: #fff; + --button-hover: #2980b9; + --log-background: #34495e; + --log-border: #2c3e50; + --log-text: #ecf0f1; + --info-color: #3498db; + --warning-color: #f39c12; + --error-color: #e74c3c; + --debug-color: #95a5a6; + --switch-bg: #7f8c8d; + --switch-on: #3498db; + --container-border: #2c3e50; + --input-border: #7f8c8d; + --input-bg: #34495e; + --settings-bg: #34495e; + --settings-border: #2c3e50; + --save-button-bg: #27ae60; + --save-button-hover: #219955; + --reset-button-bg: #e74c3c; + --reset-button-hover: #c0392b; + --donation-banner-bg: #2c3e50; + --donation-banner-border: #1a2530; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + line-height: 1.6; + transition: background-color 0.3s, color 0.3s; +} + +.container { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + display: flex; + flex-direction: column; +} + +.header { + background-color: var(--header-bg); + color: var(--header-text); + padding: 20px; + border-radius: 10px 10px 0 0; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1px; + flex-wrap: wrap; +} + +.header h1 { + font-size: 24px; + margin: 0; + white-space: nowrap; +} + +.title-link { + color: var(--header-text); + text-decoration: none; +} + +.title-link:hover { + text-decoration: underline; +} + +.edition { + font-weight: normal; + font-style: italic; + opacity: 0.8; +} + +.buttons { + display: flex; + gap: 10px; +} + +.buttons button { + background-color: var(--button-bg); + color: var(--button-text); + border: none; + padding: 8px 15px; + border-radius: 5px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s; +} + +.buttons button:hover { + background-color: var(--button-hover); +} + +.buttons button.active { + background-color: var(--button-hover); + font-weight: bold; +} + +.content-section { + flex: 1; + background-color: var(--log-background); + border: 1px solid var(--container-border); + border-radius: 0 0 10px 10px; + overflow: hidden; + display: flex; + flex-direction: column; + margin-bottom: 20px; +} + +.log-controls { + display: flex; + justify-content: space-between; + padding: 10px 20px; + background-color: var(--log-background); + border-bottom: 1px solid var(--log-border); + align-items: center; +} + +.connection-status { + font-size: 14px; +} + +.status-connected { + color: var(--save-button-bg); + font-weight: bold; +} + +.status-disconnected { + color: var(--reset-button-bg); + font-weight: bold; +} + +.auto-scroll { + font-size: 14px; + display: flex; + align-items: center; +} + +.auto-scroll input { + margin-right: 5px; +} + +.clear-button { + background-color: var(--reset-button-bg); + color: var(--button-text); + border: none; + padding: 5px 10px; + border-radius: 5px; + cursor: pointer; + font-size: 12px; + transition: background-color 0.3s; +} + +.clear-button:hover { + background-color: var(--reset-button-hover); +} + +.logs { + height: 800px; /* Fixed height */ + padding: 20px; + overflow-y: auto; + background-color: var(--log-background); + color: var(--log-text); + font-family: monospace; + white-space: pre-wrap; + word-wrap: break-word; +} + +.log-entry { + margin-bottom: 5px; + line-height: 1.4; +} + +.log-info { + color: var(--info-color); +} + +.log-warning { + color: var(--warning-color); +} + +.log-error { + color: var(--error-color); +} + +.log-debug { + color: var(--debug-color); +} + +.footer { + text-align: center; + padding: 15px 0; + font-size: 14px; + background-color: var(--header-bg); + color: var(--header-text); + border-radius: 10px; +} + +.footer a { + color: var(--button-bg); + text-decoration: none; + font-weight: bold; +} + +.footer a:hover { + text-decoration: underline; +} + +/* Theme toggle styles */ +.theme-toggle { + display: flex; + align-items: center; + margin-left: 15px; +} + +.switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; + margin-right: 10px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--switch-bg); + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 4px; + bottom: 4px; + background-color: white; + transition: .4s; +} + +input:checked + .slider { + background-color: var(--switch-on); +} + +input:checked + .slider:before { + transform: translateX(26px); +} + +.slider.round { + border-radius: 24px; +} + +.slider.round:before { + border-radius: 50%; +} + +#themeLabel { + font-size: 14px; +} + +/* Settings page styles */ +.settings-form { + padding: 20px; + overflow-y: auto; + max-height: calc(100vh - 200px); +} + +.settings-group { + background-color: var(--settings-bg); + border: 1px solid var(--settings-border); + border-radius: 8px; + padding: 15px; + margin-bottom: 20px; +} + +.settings-group h3 { + margin-bottom: 15px; + font-size: 18px; + border-bottom: 1px solid var(--settings-border); + padding-bottom: 8px; +} + +.setting-item { + margin-bottom: 15px; + display: flex; + flex-wrap: wrap; + align-items: center; +} + +.setting-item label { + width: 220px; + font-weight: bold; + margin-right: 10px; +} + +.setting-item input[type="number"] { + width: 100px; + padding: 8px; + border: 1px solid var(--input-border); + border-radius: 5px; + background-color: var(--input-bg); + color: var(--text-color); +} + +.duration-input { + display: flex; + align-items: center; +} + +#sleep_duration_hours { + margin-left: 10px; + font-size: 14px; + color: var(--info-color); +} + +.setting-help { + width: 100%; + margin-top: 5px; + margin-left: 220px; + font-size: 12px; + opacity: 0.7; +} + +.settings-buttons { + display: flex; + gap: 10px; + margin-top: 20px; + justify-content: flex-end; +} + +.save-button { + background-color: var(--save-button-bg); + color: white; + border: none; + padding: 10px 20px; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.3s; +} + +.save-button:hover { + background-color: var(--save-button-hover); +} + +.reset-button { + background-color: var(--reset-button-bg); + color: white; + border: none; + padding: 10px 20px; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.3s; +} + +.reset-button:hover { + background-color: var(--reset-button-hover); +} + +/* Toggle switch for boolean settings */ +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; + flex-shrink: 0; +} + +/* Fixed width toggle button - this is the key fix */ +.setting-item .toggle-switch { + width: 50px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + width: 50px; /* Fixed width */ + bottom: 0; + background-color: var(--switch-bg); + transition: .4s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 4px; + bottom: 4px; + background-color: white; + transition: .4s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: var(--switch-on); +} + +input:checked + .toggle-slider:before { + transform: translateX(26px); +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 15px; + align-items: flex-start; + } + + .theme-toggle { + margin-left: 0; + } + + .setting-item label { + width: 100%; + margin-bottom: 5px; + } + + .setting-help { + margin-left: 0; + } +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 00000000..e1b52b7e --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,263 @@ +document.addEventListener('DOMContentLoaded', function() { + // DOM Elements + const logsButton = document.getElementById('logsButton'); + const settingsButton = document.getElementById('settingsButton'); + const logsContainer = document.getElementById('logsContainer'); + const settingsContainer = document.getElementById('settingsContainer'); + const logsElement = document.getElementById('logs'); + const statusElement = document.getElementById('status'); + const clearLogsButton = document.getElementById('clearLogs'); + const autoScrollCheckbox = document.getElementById('autoScroll'); + const themeToggle = document.getElementById('themeToggle'); + const themeLabel = document.getElementById('themeLabel'); + + // Settings form elements + const huntMissingShowsInput = document.getElementById('hunt_missing_shows'); + const huntUpgradeEpisodesInput = document.getElementById('hunt_upgrade_episodes'); + const sleepDurationInput = document.getElementById('sleep_duration'); + const sleepDurationHoursSpan = document.getElementById('sleep_duration_hours'); + const stateResetIntervalInput = document.getElementById('state_reset_interval_hours'); + const monitoredOnlyInput = document.getElementById('monitored_only'); + const randomSelectionInput = document.getElementById('random_selection'); + const skipFutureEpisodesInput = document.getElementById('skip_future_episodes'); + const skipSeriesRefreshInput = document.getElementById('skip_series_refresh'); + const saveSettingsButton = document.getElementById('saveSettings'); + const resetSettingsButton = document.getElementById('resetSettings'); + + // Update sleep duration display + function updateSleepDurationDisplay() { + const seconds = parseInt(sleepDurationInput.value) || 900; + let displayText = ''; + + if (seconds < 60) { + displayText = `${seconds} seconds`; + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + displayText = `≈ ${minutes} minute${minutes !== 1 ? 's' : ''}`; + } else { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (minutes === 0) { + displayText = `≈ ${hours} hour${hours !== 1 ? 's' : ''}`; + } else { + displayText = `≈ ${hours} hour${hours !== 1 ? 's' : ''} ${minutes} minute${minutes !== 1 ? 's' : ''}`; + } + } + + sleepDurationHoursSpan.textContent = displayText; + } + + sleepDurationInput.addEventListener('input', updateSleepDurationDisplay); + + // Theme management + function loadTheme() { + fetch('/api/settings/theme') + .then(response => response.json()) + .then(data => { + const isDarkMode = data.dark_mode || false; + setTheme(isDarkMode); + themeToggle.checked = isDarkMode; + themeLabel.textContent = isDarkMode ? 'Dark Mode' : 'Light Mode'; + }) + .catch(error => console.error('Error loading theme:', error)); + } + + function setTheme(isDark) { + if (isDark) { + document.body.classList.add('dark-theme'); + themeLabel.textContent = 'Dark Mode'; + } else { + document.body.classList.remove('dark-theme'); + themeLabel.textContent = 'Light Mode'; + } + } + + themeToggle.addEventListener('change', function() { + const isDarkMode = this.checked; + setTheme(isDarkMode); + + fetch('/api/settings/theme', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ dark_mode: isDarkMode }) + }) + .catch(error => console.error('Error saving theme:', error)); + }); + + // Tab switching + logsButton.addEventListener('click', function() { + logsContainer.style.display = 'flex'; + settingsContainer.style.display = 'none'; + logsButton.classList.add('active'); + settingsButton.classList.remove('active'); + }); + + settingsButton.addEventListener('click', function() { + logsContainer.style.display = 'none'; + settingsContainer.style.display = 'flex'; + settingsButton.classList.add('active'); + logsButton.classList.remove('active'); + loadSettings(); + }); + + // Log management + clearLogsButton.addEventListener('click', function() { + logsElement.innerHTML = ''; + }); + + // Auto-scroll function + function scrollToBottom() { + if (autoScrollCheckbox.checked) { + logsElement.scrollTop = logsElement.scrollHeight; + } + } + + // Load settings from API + function loadSettings() { + fetch('/api/settings') + .then(response => response.json()) + .then(data => { + const huntarr = data.huntarr || {}; + + // Fill form with current 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; + sleepDurationInput.value = huntarr.sleep_duration || 900; + updateSleepDurationDisplay(); + stateResetIntervalInput.value = huntarr.state_reset_interval_hours || 168; + monitoredOnlyInput.checked = huntarr.monitored_only !== false; + randomSelectionInput.checked = huntarr.random_selection !== false; + skipFutureEpisodesInput.checked = huntarr.skip_future_episodes !== false; + skipSeriesRefreshInput.checked = huntarr.skip_series_refresh === true; + }) + .catch(error => console.error('Error loading settings:', error)); + } + + // Save settings to API + saveSettingsButton.addEventListener('click', function() { + const settings = { + huntarr: { + hunt_missing_shows: parseInt(huntMissingShowsInput.value) || 0, + hunt_upgrade_episodes: parseInt(huntUpgradeEpisodesInput.value) || 0, + sleep_duration: parseInt(sleepDurationInput.value) || 900, + state_reset_interval_hours: parseInt(stateResetIntervalInput.value) || 168, + monitored_only: monitoredOnlyInput.checked, + random_selection: randomSelectionInput.checked, + skip_future_episodes: skipFutureEpisodesInput.checked, + skip_series_refresh: skipSeriesRefreshInput.checked + } + }; + + fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(settings) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Settings saved successfully!'); + } else { + alert('Error saving settings: ' + (data.message || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error saving settings:', error); + alert('Error saving settings: ' + error.message); + }); + }); + + // Reset settings to defaults + resetSettingsButton.addEventListener('click', function() { + if (confirm('Are you sure you want to reset all settings to default values?')) { + fetch('/api/settings/reset', { + method: 'POST' + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert('Settings reset to defaults.'); + loadSettings(); + } else { + alert('Error resetting settings: ' + (data.message || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error resetting settings:', error); + alert('Error resetting settings: ' + error.message); + }); + } + }); + + // Event source for logs + let eventSource; + + function connectEventSource() { + if (eventSource) { + eventSource.close(); + } + + eventSource = new EventSource('/logs'); + + eventSource.onopen = function() { + statusElement.textContent = 'Connected'; + statusElement.className = 'status-connected'; + }; + + eventSource.onerror = function() { + statusElement.textContent = 'Disconnected'; + statusElement.className = 'status-disconnected'; + + // Attempt to reconnect after 5 seconds + setTimeout(connectEventSource, 5000); + }; + + eventSource.onmessage = function(event) { + const logEntry = document.createElement('div'); + logEntry.className = 'log-entry'; + + // Add appropriate class for log level + if (event.data.includes(' - INFO - ')) { + logEntry.classList.add('log-info'); + } else if (event.data.includes(' - WARNING - ')) { + logEntry.classList.add('log-warning'); + } else if (event.data.includes(' - ERROR - ')) { + logEntry.classList.add('log-error'); + } else if (event.data.includes(' - DEBUG - ')) { + logEntry.classList.add('log-debug'); + } + + logEntry.textContent = event.data; + logsElement.appendChild(logEntry); + + // Auto-scroll to bottom if enabled + scrollToBottom(); + }; + } + + // Observe scroll event to detect manual scrolling + logsElement.addEventListener('scroll', function() { + // If we're at the bottom or near it (within 20px), ensure auto-scroll stays on + const atBottom = (logsElement.scrollHeight - logsElement.scrollTop - logsElement.clientHeight) < 20; + if (!atBottom && autoScrollCheckbox.checked) { + // User manually scrolled up, disable auto-scroll + autoScrollCheckbox.checked = false; + } + }); + + // Re-enable auto-scroll when checkbox is checked + autoScrollCheckbox.addEventListener('change', function() { + if (this.checked) { + scrollToBottom(); + } + }); + + // Initialize + loadTheme(); + updateSleepDurationDisplay(); + connectEventSource(); +}); \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 4d332297..e8c790b2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,318 +1,127 @@ - + - Huntarr-Sonarr Log Viewer - + Huntarr-Sonarr + + -
-

Huntarr-Sonarr Log Viewer

-

Visit the Huntarr [Sonarr Edition] GITHUB Page and click the ⭐ in the Upper Right!

-
- -
- 💰 Tool Great? Donate @ https://donate.plex.one for my Daughter's College Fund! -
- -
-
Connected: Yes | Auto-refresh: Every 1 second
-
-
- - Auto-scroll ON +
+
+

Huntarr [Sonarr Edition]

+
+ +
-
-
-
- -
-
- Huntarr-Sonarr Log Viewer © 2025 -
+
+
+
+ Status: Disconnected +
+
+ +
+ +
+
+
- +
+ + +
+
+
+ + +
+ + \ No newline at end of file diff --git a/web_server.py b/web_server.py index 6ba8a09a..8e1c27d7 100644 --- a/web_server.py +++ b/web_server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Web server for Huntarr-Sonarr -Provides a web interface to view logs in real-time +Provides a web interface to view logs in real-time and manage settings """ import os @@ -9,9 +9,11 @@ import datetime import pathlib import socket -from flask import Flask, render_template, Response, stream_with_context +import json +from flask import Flask, render_template, Response, stream_with_context, request, jsonify, send_from_directory import logging from config import ENABLE_WEB_UI +import settings_manager # Check if web UI is enabled if not ENABLE_WEB_UI: @@ -35,6 +37,11 @@ def index(): """Render the main page""" return render_template('index.html') +@app.route('/static/') +def send_static(path): + """Serve static files""" + return send_from_directory('static', path) + @app.route('/logs') def stream_logs(): """Stream logs to the client""" @@ -61,6 +68,92 @@ def generate(): return Response(stream_with_context(generate()), mimetype='text/event-stream') +@app.route('/api/settings', methods=['GET']) +def get_settings(): + """Get all settings""" + return jsonify(settings_manager.get_all_settings()) + +@app.route('/api/settings', methods=['POST']) +def update_settings(): + """Update settings""" + 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 = [] + + # Update huntarr settings + 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, None) + if old_value != value: + changes_log.append(f"Changed {key} from {old_value} to {value}") + settings_manager.update_setting("huntarr", key, value) + + # Update UI settings + 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, None) + if old_value != value: + changes_log.append(f"Changed UI.{key} from {old_value} to {value}") + settings_manager.update_setting("ui", key, value) + + # Write changes to log file + if changes_log: + 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") + f.write(f"{timestamp} - huntarr-web - INFO - Settings saved successfully\n") + + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +@app.route('/api/settings/reset', methods=['POST']) +def reset_settings(): + """Reset settings to defaults""" + try: + 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") + + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + +@app.route('/api/settings/theme', methods=['GET']) +def get_theme(): + """Get the current theme setting""" + dark_mode = settings_manager.get_setting("ui", "dark_mode", True) + return jsonify({"dark_mode": dark_mode}) + +@app.route('/api/settings/theme', methods=['POST']) +def update_theme(): + """Update the theme setting""" + try: + data = request.json + old_value = settings_manager.get_setting("ui", "dark_mode", True) + if "dark_mode" in data and old_value != data["dark_mode"]: + settings_manager.update_setting("ui", "dark_mode", data["dark_mode"]) + + # Log the theme change + 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 - Changed theme from {'Dark' if old_value else 'Light'} to {'Dark' if data['dark_mode'] else 'Light'} Mode\n") + + return jsonify({"success": True}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + def get_ip_address(): """Get the host's IP address or hostname for display""" try: