Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
75 changes: 75 additions & 0 deletions frontend/static/js/settings_forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -1327,6 +1342,66 @@ const SettingsForms = {
<p class="setting-help" style="margin-left: -3ch !important;">Base URL path for reverse proxy (e.g., '/huntarr'). Leave empty for root path. Requires restart.</p>
</div>
</div>

<div class="settings-group">
<h3>Notifications</h3>
<div class="setting-item">
<label for="enable_notifications"><a href="#" class="info-icon" title="Enable or disable notifications" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>&nbsp;&nbsp;&nbsp;Enable Notifications:</label>
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
<input type="checkbox" id="enable_notifications" ${settings.enable_notifications === true ? 'checked' : ''}>
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
</label>
<p class="setting-help" style="margin-left: -3ch !important;">Enable sending notifications via Apprise</p>
</div>
<div class="setting-item">
<label for="notification_level"><a href="#" class="info-icon" title="Set minimum notification level" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>&nbsp;&nbsp;&nbsp;Notification Level:</label>
<select id="notification_level" name="notification_level" style="width: 200px; padding: 8px 12px; border-radius: 6px; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #1f2937; color: #d1d5db;">
<option value="info" ${settings.notification_level === 'info' || !settings.notification_level ? 'selected' : ''}>Info</option>
<option value="success" ${settings.notification_level === 'success' ? 'selected' : ''}>Success</option>
<option value="warning" ${settings.notification_level === 'warning' ? 'selected' : ''}>Warning</option>
<option value="error" ${settings.notification_level === 'error' ? 'selected' : ''}>Error</option>
</select>
<p class="setting-help" style="margin-left: -3ch !important;">Minimum level of events that will trigger notifications</p>
</div>
<div class="setting-item">
<label for="apprise_urls"><a href="https://github.com/caronc/apprise#supported-notifications" class="info-icon" title="Learn about Apprise URL formats" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>&nbsp;&nbsp;&nbsp;Apprise URLs:</label>
<textarea id="apprise_urls" rows="4" style="width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.1); background-color: #1f2937; color: #d1d5db;">${(settings.apprise_urls || []).join('\n')}</textarea>
<p class="setting-help" style="margin-left: -3ch !important;">Enter one Apprise URL per line (e.g., discord://, telegram://, etc)</p>
<p class="setting-help"><a href="https://github.com/caronc/apprise#supported-notifications" target="_blank">Click here for Apprise URL format documentation</a></p>
</div>
<div class="setting-item">
<label for="notify_on_missing"><a href="#" class="info-icon" title="Send notifications for missing media" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>&nbsp;&nbsp;&nbsp;Notify on Missing:</label>
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
<input type="checkbox" id="notify_on_missing" ${settings.notify_on_missing !== false ? 'checked' : ''}>
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
</label>
<p class="setting-help" style="margin-left: -3ch !important;">Send notifications when missing media is processed</p>
</div>
<div class="setting-item">
<label for="notify_on_upgrade"><a href="#" class="info-icon" title="Send notifications for upgrades" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>&nbsp;&nbsp;&nbsp;Notify on Upgrade:</label>
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
<input type="checkbox" id="notify_on_upgrade" ${settings.notify_on_upgrade !== false ? 'checked' : ''}>
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
</label>
<p class="setting-help" style="margin-left: -3ch !important;">Send notifications when media is upgraded</p>
</div>
<div class="setting-item">
<label for="notification_include_instance"><a href="#" class="info-icon" title="Include instance name in notifications" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>&nbsp;&nbsp;&nbsp;Include Instance:</label>
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
<input type="checkbox" id="notification_include_instance" ${settings.notification_include_instance !== false ? 'checked' : ''}>
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
</label>
<p class="setting-help" style="margin-left: -3ch !important;">Include instance name in notification messages</p>
</div>
<div class="setting-item">
<label for="notification_include_app"><a href="#" class="info-icon" title="Include app name in notifications" target="_blank" rel="noopener"><i class="fas fa-info-circle"></i></a>&nbsp;&nbsp;&nbsp;Include App Name:</label>
<label class="toggle-switch" style="width:40px; height:20px; display:inline-block; position:relative;">
<input type="checkbox" id="notification_include_app" ${settings.notification_include_app !== false ? 'checked' : ''}>
<span class="toggle-slider" style="position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background-color:#3d4353; border-radius:20px; transition:0.4s;"></span>
</label>
<p class="setting-help" style="margin-left: -3ch !important;">Include app name (Sonarr, Radarr, etc.) in notification messages</p>
</div>
</div>
`;

// Get hours input and days span elements once
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
pywin32==306; sys_platform == 'win32' # For Windows service support
apprise==1.6.0 # Added for notification support
5 changes: 5 additions & 0 deletions src/primary/default_configs/general.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/primary/history_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
215 changes: 215 additions & 0 deletions src/primary/notification_manager.py
Original file line number Diff line number Diff line change
@@ -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}")
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7.0.7
7.0.8