#!/usr/bin/env python3 """ Pushover Notification Module Sends professional push notifications when new media is downloaded """ import os import requests from datetime import datetime from typing import Dict, Optional, Any from pathlib import Path from modules.universal_logger import get_logger logger = get_logger('Notifier') class PushoverNotifier: """Handles Pushover push notifications for media downloads""" # Pushover API endpoint API_URL = "https://api.pushover.net/1/messages.json" # Plural forms for proper grammar PLURALS = { 'story': 'stories', 'video': 'videos', 'photo': 'photos', 'image': 'images', 'reel': 'reels', 'post': 'posts', 'thread': 'threads', 'item': 'items', 'media': 'media', # Already plural (singular: medium) 'tagged': 'tagged', # "Tagged" doesn't change in plural (7 Tagged Photos) 'audio': 'audio', # Uncountable (3 Audio Downloaded) } # Priority levels PRIORITY_LOW = -2 PRIORITY_NORMAL = -1 PRIORITY_DEFAULT = 0 PRIORITY_HIGH = 1 PRIORITY_EMERGENCY = 2 # Platform emoji/icons for better visual appeal PLATFORM_ICONS = { 'instagram': '๐Ÿ“ธ', 'fastdl': '๐Ÿ“ธ', 'imginn': '๐Ÿ“ธ', 'toolzu': '๐Ÿ“ธ', 'tiktok': '๐ŸŽต', 'forums': '๐Ÿ’ฌ', 'snapchat': '๐Ÿ‘ป', 'youtube': 'โ–ถ๏ธ', 'twitter': '๐Ÿฆ', 'easynews': '๐Ÿ“ฐ', } # Platform name mapping (service name -> user-friendly platform name) PLATFORM_NAMES = { 'fastdl': 'Instagram', 'imginn': 'Instagram', 'toolzu': 'Instagram', 'instagram': 'Instagram', 'tiktok': 'TikTok', 'snapchat': 'Snapchat', 'forums': 'Forum', 'easynews': 'Easynews', } # Content type icons CONTENT_ICONS = { 'post': '๐Ÿ–ผ๏ธ', 'story': 'โญ', 'reel': '๐ŸŽฌ', 'video': '๐ŸŽฅ', 'image': '๐Ÿ–ผ๏ธ', 'thread': '๐Ÿงต', 'photo': '๐Ÿ“ท', 'audio': '๐ŸŽต', } def __init__(self, user_key: str, api_token: str, enabled: bool = True, default_priority: int = 0, device: str = None, include_image: bool = True, unified_db=None, enable_review_queue_notifications: bool = True): """ Initialize Pushover notifier Args: user_key: Your Pushover user key api_token: Your Pushover application API token enabled: Whether notifications are enabled default_priority: Default notification priority (-2 to 2) device: Specific device name to send to (optional) include_image: Whether to include image thumbnails in notifications (default: True) unified_db: UnifiedDatabase instance for recording notifications (optional) enable_review_queue_notifications: Whether to send push notifications for review queue items (default: True) """ self.user_key = user_key self.api_token = api_token self.enabled = enabled self.default_priority = default_priority self.device = device self.include_image = include_image self.unified_db = unified_db self.enable_review_queue_notifications = enable_review_queue_notifications self.stats = { 'sent': 0, 'failed': 0, 'skipped': 0 } # Tracking for database recording self._current_notification_context = None def _record_notification(self, title: str, message: str, priority: int, status: str, response_data: dict, image_path: str = None): """Record notification to database Args: title: Notification title message: Notification message priority: Priority level status: Status ('sent' or 'failed') response_data: Response from Pushover API image_path: Optional path to thumbnail image """ if not self.unified_db: logger.debug("[Pushover] No database connection available for recording notification") return if not self._current_notification_context: logger.debug("[Pushover] No notification context available for recording") return try: import json context = self._current_notification_context # Add image path to metadata if provided metadata = context.get('metadata', {}) or {} if image_path: metadata['image_path'] = str(image_path) with self.unified_db.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" INSERT INTO notifications ( platform, source, content_type, message, title, priority, download_count, sent_at, status, response_data, metadata ) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, ?) """, ( context.get('platform'), context.get('source'), context.get('content_type'), message, title, priority, context.get('download_count', 1), status, json.dumps(response_data) if response_data else None, json.dumps(metadata) if metadata else None )) conn.commit() logger.info(f"[Pushover] Recorded notification to database: {title} (status: {status})") # Broadcast to frontend for real-time toast notification try: from web.backend.api import manager if manager and manager.active_connections: manager.broadcast_sync({ 'type': 'notification_created', 'notification': { 'title': title, 'message': message, 'platform': context.get('platform'), 'source': context.get('source'), 'content_type': context.get('content_type'), 'download_count': context.get('download_count', 1), 'status': status, } }) except Exception: # Fail silently - API may not be running or manager not available pass # Clear context after recording to prevent stale data on subsequent notifications self._current_notification_context = None except Exception as e: logger.warning(f"[Pushover] Failed to record notification to database: {e}") import traceback logger.warning(f"[Pushover] Traceback: {traceback.format_exc()}") def _get_platform_display_name(self, platform: str, source: str = None) -> str: """ Convert service name to user-friendly platform name Args: platform: Service/platform name (fastdl, imginn, toolzu, etc.) source: Source/username (for forums, this is the forum name) Returns: User-friendly platform name (Instagram, TikTok, etc.) """ platform_lower = platform.lower() # For forums, use the forum name (source) as the platform display name if platform_lower == 'forums' and source: return source.title() return self.PLATFORM_NAMES.get(platform_lower, platform.title()) def _pluralize(self, word: str, count: int) -> str: """ Get the correct plural form of a word Args: word: Singular word count: Count to determine if plural needed Returns: Singular or plural form """ # Handle None or empty word if not word: return "items" if count != 1 else "item" if count == 1: return word # Check if we have a custom plural word_lower = word.lower() if word_lower in self.PLURALS: return self.PLURALS[word_lower].title() if word[0].isupper() else self.PLURALS[word_lower] # Check if word is already a plural form (value in PLURALS) if word_lower in self.PLURALS.values(): return word # Already plural, return as-is # Default: just add 's' (but not if already ends with 's') if word_lower.endswith('s'): return word return f"{word}s" def _extract_random_video_frame(self, video_path: str) -> str: """ Extract a random frame from a video file Args: video_path: Path to the video file Returns: Path to extracted frame (temp file) or None if extraction failed """ import subprocess import random import tempfile try: # Get video duration using ffprobe ffprobe_cmd = [ 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', video_path ] result = subprocess.run( ffprobe_cmd, capture_output=True, text=True, timeout=10 ) if result.returncode != 0: logger.warning(f"[Pushover] ffprobe failed to get video duration: {result.stderr[:200]}") return None duration = float(result.stdout.strip()) # Skip first and last 10% to avoid black frames start_offset = duration * 0.1 end_offset = duration * 0.9 if end_offset <= start_offset: # Video too short, just use middle timestamp = duration / 2 else: # Pick random timestamp in the middle 80% timestamp = random.uniform(start_offset, end_offset) logger.debug(f"[Pushover] Video duration: {duration:.2f}s, extracting frame at {timestamp:.2f}s") # Create temp file for the frame temp_fd, temp_path = tempfile.mkstemp(suffix='.jpg', prefix='pushover_frame_') os.close(temp_fd) # Close the file descriptor, ffmpeg will write to it success = False try: # Extract frame using ffmpeg ffmpeg_cmd = [ 'ffmpeg', '-ss', str(timestamp), # Seek to timestamp '-i', video_path, # Input file '-vframes', '1', # Extract 1 frame '-q:v', '2', # High quality '-y', # Overwrite output temp_path ] result = subprocess.run( ffmpeg_cmd, capture_output=True, text=True, timeout=30 ) if result.returncode != 0: logger.debug(f"[Pushover] ffmpeg failed: {result.stderr}") return None # Verify the frame was created if Path(temp_path).exists() and Path(temp_path).stat().st_size > 0: success = True return temp_path else: logger.debug("[Pushover] Frame extraction produced empty file") return None except subprocess.TimeoutExpired: logger.debug("[Pushover] Video frame extraction timed out") return None finally: # Clean up temp file if extraction failed if not success: try: Path(temp_path).unlink(missing_ok=True) except OSError: pass except Exception as e: logger.debug(f"[Pushover] Error extracting video frame: {e}") return None def send_notification(self, title: str, message: str, priority: int = None, url: str = None, url_title: str = None, sound: str = None, device: str = None, html: bool = False, image_path: str = None, max_retries: int = 3, retry_delay: int = 5) -> bool: """ Send a Pushover notification with automatic retry on transient failures Args: title: Notification title message: Notification message priority: Priority level (-2 to 2) url: Supplementary URL url_title: Title for the URL sound: Notification sound name device: Specific device to send to html: Enable HTML formatting image_path: Path to image file to attach as thumbnail max_retries: Maximum number of retry attempts (default 3) retry_delay: Initial retry delay in seconds, doubles each retry (default 5) Returns: True if notification sent successfully """ if not self.enabled: logger.debug("[Pushover] Notifications disabled, skipping") self.stats['skipped'] += 1 return False if not self.user_key or not self.api_token: logger.warning("[Pushover] Missing user_key or api_token") self.stats['failed'] += 1 return False # Normalize priority actual_priority = priority if priority is not None else self.default_priority # Prepare payload payload = { 'token': self.api_token, 'user': self.user_key, 'title': title, 'message': message, 'priority': actual_priority, } # Add optional parameters if url: payload['url'] = url if url_title: payload['url_title'] = url_title if sound: payload['sound'] = sound if device or self.device: payload['device'] = device or self.device if html: payload['html'] = 1 # Retry loop with exponential backoff for attempt in range(max_retries): try: # Check if we have an image to attach files = None if image_path: from pathlib import Path img_path = Path(image_path) # Only attach if file exists and is an image if img_path.exists() and img_path.suffix.lower() in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']: try: # Determine MIME type mime_type = 'image/jpeg' if img_path.suffix.lower() == '.png': mime_type = 'image/png' elif img_path.suffix.lower() == '.gif': mime_type = 'image/gif' elif img_path.suffix.lower() == '.bmp': mime_type = 'image/bmp' elif img_path.suffix.lower() == '.webp': mime_type = 'image/webp' # Open and attach the image files = {'attachment': (img_path.name, open(img_path, 'rb'), mime_type)} logger.debug(f"[Pushover] Attaching image: {img_path.name}") except Exception as e: logger.warning(f"[Pushover] Failed to attach image {image_path}: {e}") response = requests.post(self.API_URL, data=payload, files=files, timeout=30) # Close file if opened if files and 'attachment' in files: files['attachment'][1].close() if response.status_code == 200: result = response.json() if result.get('status') == 1: request_id = result.get('request', 'unknown') if attempt > 0: logger.info(f"[Pushover] Notification sent after {attempt + 1} attempt(s): {title} (request: {request_id})") else: logger.info(f"[Pushover] Notification sent: {title} (request: {request_id})") self.stats['sent'] += 1 # Record to database if available and we have context self._record_notification(title, message, actual_priority, 'sent', result, image_path) return True else: # API returned error status - don't retry client errors logger.error(f"[Pushover] API error: {result}") self.stats['failed'] += 1 # Record failure to database self._record_notification(title, message, actual_priority, 'failed', result, image_path) return False # Handle HTTP errors with retry logic elif response.status_code >= 500: # Server error (5xx) - retry with backoff if attempt < max_retries - 1: wait_time = retry_delay * (2 ** attempt) logger.warning(f"[Pushover] HTTP {response.status_code}: {response.text[:100]}, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})") import time time.sleep(wait_time) continue else: # Max retries exceeded logger.error(f"[Pushover] HTTP {response.status_code} after {max_retries} attempts: {response.text}") self.stats['failed'] += 1 self._record_notification(title, message, actual_priority, 'failed', {'error': f"HTTP {response.status_code} after {max_retries} retries"}, image_path) return False else: # Client error (4xx) - don't retry logger.error(f"[Pushover] HTTP {response.status_code}: {response.text}") self.stats['failed'] += 1 self._record_notification(title, message, actual_priority, 'failed', {'error': response.text}, image_path) return False except (requests.ConnectionError, requests.Timeout) as e: # Network errors - retry with backoff if attempt < max_retries - 1: wait_time = retry_delay * (2 ** attempt) logger.warning(f"[Pushover] Network error: {e}, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})") import time time.sleep(wait_time) continue else: # Max retries exceeded logger.error(f"[Pushover] Network error after {max_retries} attempts: {e}") self.stats['failed'] += 1 self._record_notification(title, message, actual_priority, 'failed', {'error': f"Network error after {max_retries} retries: {str(e)}"}, image_path) return False except Exception as e: # Other exceptions - don't retry logger.error(f"[Pushover] Failed to send notification: {e}") self.stats['failed'] += 1 self._record_notification(title, message, actual_priority, 'failed', {'error': str(e)}, image_path) return False # Should never reach here, but just in case return False def notify_download(self, platform: str, source: str, content_type: str, filename: str = None, search_term: str = None, count: int = 1, metadata: Dict[str, Any] = None, priority: int = None) -> bool: """ Send a professional notification for a new download Args: platform: Platform name (instagram, tiktok, forum, etc.) source: Username or source identifier content_type: Type of content (post, story, reel, thread, etc.) filename: Optional filename search_term: Optional search term (for forum searches) count: Number of items downloaded (default 1) metadata: Additional metadata dictionary priority: Notification priority Returns: True if notification sent successfully """ metadata = metadata or {} # Handle None content_type content_type = content_type or 'item' # Get appropriate icons platform_icon = self.PLATFORM_ICONS.get(platform.lower(), '๐Ÿ“ฅ') content_icon = self.CONTENT_ICONS.get(content_type.lower(), '๐Ÿ“„') # Build title with proper grammar if count > 1: plural_type = self._pluralize(content_type, count) title = f"{platform_icon} {count} {plural_type.title()} Downloaded" else: title = f"{platform_icon} New {content_type.title()} Downloaded" # Build message message_parts = [] # Add platform (convert service name to user-friendly platform name) # For forums, use forum name; for Instagram services, use "Instagram" platform_display = self._get_platform_display_name(platform, source) message_parts.append(f"๐Ÿ“ฑ Platform: {platform_display}") # Add source/username (skip for forums since source becomes the platform name) if source and platform.lower() != 'forums': message_parts.append(f"{content_icon} Source: {source}") # Add search term if available if search_term: message_parts.append(f"๐Ÿ” Search: {search_term}") # Add post date if available if metadata.get('post_date'): try: if isinstance(metadata['post_date'], str): post_date = datetime.fromisoformat(metadata['post_date']) else: post_date = metadata['post_date'] date_str = post_date.strftime("%Y-%m-%d %H:%M") message_parts.append(f"๐Ÿ“… Posted: {date_str}") except Exception: pass # Add timestamp now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") message_parts.append(f"โฐ Downloaded: {now}") message = "\n".join(message_parts) # Set context for database recording self._current_notification_context = { 'platform': platform, 'source': source, 'content_type': content_type, 'download_count': count, 'metadata': {'search_term': search_term} if search_term else metadata } # Determine sound based on platform or priority sound = None if priority and priority >= self.PRIORITY_HIGH: sound = "pushover" # Default urgent sound return self.send_notification( title=title, message=message, priority=priority, sound=sound, html=True ) def notify_batch_download(self, platform: str, downloads: list, search_term: str = None, is_review_queue: bool = False) -> bool: """ Send notification for batch downloads Args: platform: Platform name downloads: List of download dicts with keys: source, content_type, filename, file_path search_term: Optional search term is_review_queue: True if these are review queue items (no face match) Returns: True if notification sent successfully """ if not downloads: return False # Check if review queue notifications are disabled # Always check current database value for review queue notifications if is_review_queue: if self.unified_db: try: from modules.settings_manager import SettingsManager settings_manager = SettingsManager(str(self.unified_db.db_path)) pushover_settings = settings_manager.get('pushover', {}) enable_review_notifications = pushover_settings.get('enable_review_queue_notifications', True) if not enable_review_notifications: logger.debug("[Pushover] Skipping review queue notification (disabled in settings)") return False except Exception as e: logger.warning(f"[Pushover] Could not check review queue notification setting, using cached value: {e}") # Fall back to cached value if not self.enable_review_queue_notifications: logger.debug("[Pushover] Skipping review queue notification (disabled in cached settings)") return False else: # No database, use cached value if not self.enable_review_queue_notifications: logger.debug("[Pushover] Skipping review queue notification (disabled in settings)") return False # Extract source from first download source = None if downloads and downloads[0].get('source'): source = downloads[0]['source'] # Extract content type (handle None explicitly) content_type = (downloads[0].get('content_type') or 'item') if downloads else 'item' # Collect all media file paths for the notification database record image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic', '.heif', '.avif', '.tiff', '.tif'} video_extensions = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv'} audio_extensions = {'.mp3', '.wav', '.flac', '.aac', '.m4a', '.ogg', '.wma'} all_media_paths = [] for dl in downloads: file_path = dl.get('file_path') if file_path and Path(file_path).exists(): suffix = Path(file_path).suffix.lower() ct = dl.get('content_type', '').lower() if ct == 'audio' or suffix in audio_extensions: media_type = 'audio' elif ct == 'image' or suffix in image_extensions: media_type = 'image' elif ct == 'video' or suffix in video_extensions: media_type = 'video' else: continue all_media_paths.append({ 'file_path': file_path, 'filename': dl.get('filename', Path(file_path).name), 'media_type': media_type }) # Set context for database recording with all media files metadata = {} if search_term: metadata['search_term'] = search_term if all_media_paths: metadata['media_files'] = all_media_paths # Store all media files for notifications page self._current_notification_context = { 'platform': platform, 'source': source, 'content_type': content_type, 'download_count': len(downloads), 'metadata': metadata if metadata else None } # Use different icon for review queue if is_review_queue: platform_icon = "๐Ÿ‘๏ธ" # Eye icon for review else: platform_icon = self.PLATFORM_ICONS.get(platform.lower(), '๐Ÿ“ฅ') # Group by content type by_type = {} for dl in downloads: content_type = dl.get('content_type') or 'item' # Handle None explicitly by_type.setdefault(content_type, []).append(dl) # Build title with proper grammar total = len(downloads) if is_review_queue: # Review queue notification if len(by_type) == 1: content_type = list(by_type.keys())[0] plural_type = self._pluralize(content_type, total) title = f"{platform_icon} {total} {plural_type.title()} - Review Queue" else: title = f"{platform_icon} {total} Items - Review Queue" else: # Regular download notification if len(by_type) == 1: # Single content type - use specific name content_type = list(by_type.keys())[0] plural_type = self._pluralize(content_type, total) title = f"{platform_icon} {total} {plural_type.title()} Downloaded" else: # Multiple content types - use "Items" title = f"{platform_icon} {total} Items Downloaded" # Build message message_parts = [] # Extract source from first download since they're all from same source source = None if downloads and downloads[0].get('source'): source = downloads[0]['source'] # Add platform (convert service name to user-friendly platform name) # For forums, use forum name; for Instagram services, use "Instagram" platform_display = self._get_platform_display_name(platform, source) message_parts.append(f"๐Ÿ“ฑ Platform: {platform_display}") # Add source/username (skip for forums since source becomes the platform name) if source and platform.lower() != 'forums': # Get content icon for the primary content type primary_content_type = list(by_type.keys())[0] if by_type else 'item' content_icon = self.CONTENT_ICONS.get(primary_content_type.lower(), '๐Ÿ“„') message_parts.append(f"{content_icon} Source: {source}") if search_term: message_parts.append(f"๐Ÿ” Search: {search_term}") # Add review queue notice if applicable if is_review_queue: message_parts.append(f"\nโš ๏ธ No face match detected - Items moved to review queue for manual review") # Summary by type (only show if multiple types) if len(by_type) > 1: message_parts.append(f"\nBreakdown:") for content_type, items in by_type.items(): content_icon = self.CONTENT_ICONS.get(content_type.lower(), '๐Ÿ“„') count = len(items) plural_type = self._pluralize(content_type, count) message_parts.append(f"{content_icon} {count} {plural_type}") # Add timestamp now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") message_parts.append(f"\nโฐ Downloaded: {now}") message = "\n".join(message_parts) # Select a random file for thumbnail attachment (if enabled) # Can be an image or video (extract random frame from video) import random image_path = None temp_frame_path = None # Track temporary frame extractions if self.include_image: image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'} video_extensions = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'} # Collect all valid media file paths (images and videos) media_files = [] for dl in downloads: file_path = dl.get('file_path') if file_path: exists = Path(file_path).exists() if exists: suffix = Path(file_path).suffix.lower() if suffix in image_extensions or suffix in video_extensions: media_files.append(file_path) else: logger.debug(f"[Pushover] Skipping file (invalid extension): {Path(file_path).name} ({suffix})") else: logger.warning(f"[Pushover] Skipping file (doesn't exist): {file_path}") else: logger.warning(f"[Pushover] Download entry has no file_path") logger.debug(f"[Pushover] Found {len(media_files)} valid media files out of {len(downloads)} downloads") # Randomly select one file if available if media_files: selected_file = random.choice(media_files) selected_suffix = Path(selected_file).suffix.lower() if selected_suffix in image_extensions: # It's an image, use directly image_path = selected_file logger.debug(f"[Pushover] Selected image thumbnail: {Path(image_path).name}") elif selected_suffix in video_extensions: # It's a video, extract a random frame logger.info(f"[Pushover] Selected video for thumbnail, extracting random frame: {Path(selected_file).name}") temp_frame_path = self._extract_random_video_frame(selected_file) if temp_frame_path: image_path = temp_frame_path logger.info(f"[Pushover] Successfully extracted video frame for thumbnail: {Path(temp_frame_path).name}") else: logger.warning("[Pushover] Failed to extract frame from video - notification will be sent without thumbnail") else: logger.debug("[Pushover] No media files available for thumbnail attachment") else: logger.debug("[Pushover] Image thumbnails disabled in settings") # Send notification with lower priority for review queue priority = -1 if is_review_queue else None # Low priority for review queue result = self.send_notification( title=title, message=message, html=True, image_path=image_path, priority=priority ) # Clean up temporary frame file if we created one if temp_frame_path and Path(temp_frame_path).exists(): try: Path(temp_frame_path).unlink() logger.debug(f"[Pushover] Cleaned up temp frame: {Path(temp_frame_path).name}") except Exception as e: logger.debug(f"[Pushover] Failed to cleanup temp frame: {e}") return result def notify_error(self, platform: str, error_message: str, source: str = None) -> bool: """ Send error notification Args: platform: Platform name error_message: Error description source: Optional source/username Returns: True if notification sent successfully """ # Convert service name to user-friendly platform name # For forums, use forum name; for Instagram services, use "Instagram" platform_display = self._get_platform_display_name(platform, source) title = f"โš ๏ธ {platform_display} Download Error" message_parts = [ f"Platform: {platform_display}", ] # Add source (skip for forums since source becomes the platform name) if source and platform.lower() != 'forums': message_parts.append(f"Source: {source}") message_parts.append(f"\nError: {error_message}") message = "\n".join(message_parts) return self.send_notification( title=title, message=message, priority=self.PRIORITY_HIGH, sound="siren", html=True ) def get_stats(self) -> Dict[str, int]: """Get notification statistics""" return self.stats.copy() def reset_stats(self): """Reset statistics""" self.stats = { 'sent': 0, 'failed': 0, 'skipped': 0 } def create_notifier_from_config(config: Dict, unified_db=None) -> Optional[PushoverNotifier]: """ Create a PushoverNotifier from configuration dictionary Args: config: Configuration dict with pushover settings unified_db: UnifiedDatabase instance for recording notifications (optional) Returns: PushoverNotifier instance or None if disabled/invalid """ pushover_config = config.get('pushover', {}) if not pushover_config.get('enabled', False): logger.info("[Pushover] Notifications disabled in config") return None user_key = pushover_config.get('user_key') api_token = pushover_config.get('api_token') if not user_key or not api_token: logger.warning("[Pushover] Missing user_key or api_token in config") return None return PushoverNotifier( user_key=user_key, api_token=api_token, enabled=True, default_priority=pushover_config.get('priority', 0), device=pushover_config.get('device'), include_image=pushover_config.get('include_image', True), unified_db=unified_db, enable_review_queue_notifications=pushover_config.get('enable_review_queue_notifications', True) ) if __name__ == "__main__": # Test the notifier print("Testing Pushover Notifier...") # This is a test - replace with your actual credentials notifier = PushoverNotifier( user_key="YOUR_USER_KEY", api_token="YOUR_API_TOKEN", enabled=False # Set to True to test ) # Test notification notifier.notify_download( platform="instagram", source="evalongoria", content_type="story", filename="evalongoria_story_20251018.mp4", metadata={'post_date': datetime.now()} ) print(f"Stats: {notifier.get_stats()}")