diff --git a/README.md b/README.md index 1b3710b1..897c9c88 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ Learn more about **Cleanuperr** at [https://github.com/flmorg/cleanuperr](https: - **API Rate Limits**: Configure hourly API caps to prevent rate limiting by your indexers - **Universal Timeouts**: All apps use consistent 120s timeouts for reliable command completion - **Monitored Only**: Filter searches to focus only on content you've marked as monitored +- **Notifications**: Configure Apprise notifications to get alerts when media is processed ## Troubleshooting diff --git a/frontend/static/js/settings_forms.js b/frontend/static/js/settings_forms.js index 7dde0a22..5062c4f5 100644 --- a/frontend/static/js/settings_forms.js +++ b/frontend/static/js/settings_forms.js @@ -1062,6 +1062,21 @@ const SettingsForms = { settings.ssl_verify = getInputValue('#ssl_verify', true); settings.stateful_management_hours = getInputValue('#stateful_management_hours', 168); + // Notification settings + settings.enable_notifications = getInputValue('#enable_notifications', false); + settings.notification_level = container.querySelector('#notification_level')?.value || 'info'; + + // Process apprise URLs (split by newline) + const appriseUrlsText = container.querySelector('#apprise_urls')?.value || ''; + settings.apprise_urls = appriseUrlsText.split('\n') + .map(url => url.trim()) + .filter(url => url.length > 0); + + settings.notify_on_missing = getInputValue('#notify_on_missing', true); + settings.notify_on_upgrade = getInputValue('#notify_on_upgrade', true); + settings.notification_include_instance = getInputValue('#notification_include_instance', true); + settings.notification_include_app = getInputValue('#notification_include_app', true); + // Handle the auth_mode dropdown const authMode = container.querySelector('#auth_mode')?.value || 'login'; @@ -1327,6 +1342,66 @@ const SettingsForms = {

Base URL path for reverse proxy (e.g., '/huntarr'). Leave empty for root path. Requires restart.

+ +
+

Notifications

+
+ + +

Enable sending notifications via Apprise

+
+
+ + +

Minimum level of events that will trigger notifications

+
+
+ + +

Enter one Apprise URL per line (e.g., discord://, telegram://, etc)

+

Click here for Apprise URL format documentation

+
+
+ + +

Send notifications when missing media is processed

+
+
+ + +

Send notifications when media is upgraded

+
+
+ + +

Include instance name in notification messages

+
+
+ + +

Include app name (Sonarr, Radarr, etc.) in notification messages

+
+
`; // Get hours input and days span elements once diff --git a/requirements.txt b/requirements.txt index 419b6dee..8c3a852d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ waitress==2.1.2 bcrypt==4.1.2 qrcode[pil]==7.4.2 # Added qrcode with PIL support pyotp==2.9.0 # Added pyotp -pywin32==306; sys_platform == 'win32' # For Windows service support \ No newline at end of file +pywin32==306; sys_platform == 'win32' # For Windows service support +apprise==1.6.0 # Added for notification support \ No newline at end of file diff --git a/src/primary/default_configs/general.json b/src/primary/default_configs/general.json index 6cd5021d..5e71e829 100644 --- a/src/primary/default_configs/general.json +++ b/src/primary/default_configs/general.json @@ -6,6 +6,11 @@ "check_for_updates": true, "enable_notifications": false, "notification_level": "info", + "apprise_urls": [], + "notify_on_missing": true, + "notify_on_upgrade": true, + "notification_include_instance": true, + "notification_include_app": true, "local_access_bypass": false, "proxy_auth_bypass": false, "stateful_management_hours": 168, diff --git a/src/primary/history_manager.py b/src/primary/history_manager.py index 2cd939e3..ffb84059 100644 --- a/src/primary/history_manager.py +++ b/src/primary/history_manager.py @@ -120,6 +120,15 @@ def add_history_entry(app_type, entry_data): json.dump(history_data, f, indent=2) logger.info(f"Added history entry for {app_type}-{instance_name}: {entry_data['name']}") + + # Send notification about this history entry + try: + # Import here to avoid circular imports + from src.primary.notification_manager import send_history_notification + send_history_notification(entry) + except Exception as e: + logger.error(f"Failed to send notification for history entry: {e}") + return entry def get_history(app_type, search_query=None, page=1, page_size=20): diff --git a/src/primary/notification_manager.py b/src/primary/notification_manager.py new file mode 100644 index 00000000..23303b87 --- /dev/null +++ b/src/primary/notification_manager.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Notification manager for Huntarr +Handles sending notifications via Apprise +""" + +import logging +import json +from typing import Dict, Any, Optional, List + +# Lazy import Apprise to avoid startup issues if the package is not installed +apprise_import_error = None +try: + import apprise +except ImportError as e: + apprise_import_error = str(e) + +# Create a logger for the notification manager +logger = logging.getLogger(__name__) + +# Import the settings manager +from src.primary.settings_manager import get_setting, load_settings + +def get_notification_config(): + """ + Get the notification configuration from general settings + + Returns: + dict: The notification configuration + """ + general_settings = load_settings('general') + notification_config = { + 'enabled': general_settings.get('enable_notifications', False), + 'level': general_settings.get('notification_level', 'info'), + 'apprise_urls': general_settings.get('apprise_urls', []), + 'notify_on_missing': general_settings.get('notify_on_missing', True), + 'notify_on_upgrade': general_settings.get('notify_on_upgrade', True), + 'include_instance_name': general_settings.get('notification_include_instance', True), + 'include_app_name': general_settings.get('notification_include_app', True) + } + + return notification_config + +def create_apprise_object(): + """ + Create and configure an Apprise object with the URLs from settings + + Returns: + apprise.Apprise: Configured Apprise object or None if there was an error + """ + if apprise_import_error: + logger.error(f"Apprise is not available: {apprise_import_error}") + return None + + config = get_notification_config() + + if not config['enabled'] or not config['apprise_urls']: + return None + + try: + # Create an Apprise instance + apobj = apprise.Apprise() + + # Add all the URLs to our Apprise object + for url in config['apprise_urls']: + if url and url.strip(): + added = apobj.add(url.strip()) + if added: + logger.debug(f"Added Apprise URL: {url[:15]}...") + else: + logger.warning(f"Failed to add Apprise URL: {url[:15]}...") + + return apobj + except Exception as e: + logger.error(f"Error creating Apprise object: {e}") + return None + +def send_notification(title, message, level='info', attach=None): + """ + Send a notification via Apprise + + Args: + title (str): The notification title + message (str): The notification message + level (str): The notification level (info, success, warning, error) + attach (str, optional): Path to a file to attach + + Returns: + bool: True if notification was sent successfully, False otherwise + """ + if apprise_import_error: + logger.error(f"Cannot send notification, Apprise is not available: {apprise_import_error}") + return False + + config = get_notification_config() + + if not config['enabled']: + logger.debug("Notifications are disabled in settings") + return False + + # Check if the notification level is high enough to send + levels = { + 'debug': 0, + 'info': 1, + 'success': 1, + 'warning': 2, + 'error': 3 + } + + if levels.get(level, 0) < levels.get(config['level'], 1): + logger.debug(f"Notification level {level} is below configured level {config['level']}") + return False + + # Create Apprise object + apobj = create_apprise_object() + if not apobj: + return False + + # Set notification type based on level + notify_type = apprise.NotifyType.INFO + + if level == 'success': + notify_type = apprise.NotifyType.SUCCESS + elif level == 'warning': + notify_type = apprise.NotifyType.WARNING + elif level == 'error': + notify_type = apprise.NotifyType.FAILURE + + try: + # Send notification + result = apobj.notify( + body=message, + title=title, + notify_type=notify_type, + attach=attach + ) + + logger.info(f"Notification sent (level={level}): {title}") + return result + + except Exception as e: + logger.error(f"Failed to send notification: {e}") + return False + +def send_history_notification(entry_data, operation_type=None): + """ + Send a notification about a history entry + + Args: + entry_data (dict): The history entry data + operation_type (str, optional): Override the operation type + + Returns: + bool: True if notification was sent successfully, False otherwise + """ + config = get_notification_config() + + if not config['enabled']: + return False + + # Skip if we shouldn't notify on this operation type + op_type = operation_type or entry_data.get('operation_type', 'missing') + if op_type == 'missing' and not config.get('notify_on_missing', True): + return False + if op_type == 'upgrade' and not config.get('notify_on_upgrade', True): + return False + + # Determine notification level based on operation type + level = 'info' + if op_type == 'error': + level = 'error' + elif op_type == 'upgrade': + level = 'success' + + # Build notification title + title_parts = ["Huntarr"] + + if config.get('include_app_name', True) and 'app_type' in entry_data: + app_type = entry_data['app_type'] + # Capitalize app name + title_parts.append(app_type.capitalize()) + + if config.get('include_instance_name', True) and 'instance_name' in entry_data: + title_parts.append(f"({entry_data['instance_name']})") + + title = " ".join(title_parts) + + # Build notification message + if op_type == 'missing': + message = f"Added Missing: {entry_data.get('processed_info', 'Unknown')}" + elif op_type == 'upgrade': + message = f"Added Upgrade: {entry_data.get('processed_info', 'Unknown')}" + elif op_type == 'error': + message = f"Error Processing: {entry_data.get('processed_info', 'Unknown')}" + else: + message = f"{op_type.capitalize()}: {entry_data.get('processed_info', 'Unknown')}" + + # Send the notification + return send_notification(title, message, level=level) + +# Example usage (for testing) +if __name__ == "__main__": + import sys + logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S') + + # Test notification + result = send_notification( + title="Huntarr Test Notification", + message="This is a test notification from Huntarr", + level="info" + ) + + logger.info(f"Notification result: {result}") \ No newline at end of file diff --git a/version.txt b/version.txt index bf993904..19300b7b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -7.0.7 \ No newline at end of file +7.0.8 \ No newline at end of file