Files
media-downloader/modules/pushover_notifier.py
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

962 lines
38 KiB
Python

#!/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"📱 <b>Platform:</b> {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} <b>Source:</b> {source}")
# Add search term if available
if search_term:
message_parts.append(f"🔍 <b>Search:</b> {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"📅 <b>Posted:</b> {date_str}")
except Exception:
pass
# Add timestamp
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
message_parts.append(f"⏰ <b>Downloaded:</b> {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"📱 <b>Platform:</b> {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} <b>Source:</b> {source}")
if search_term:
message_parts.append(f"🔍 <b>Search:</b> {search_term}")
# Add review queue notice if applicable
if is_review_queue:
message_parts.append(f"\n⚠️ <b>No face match detected</b> - 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"\n<b>Breakdown:</b>")
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⏰ <b>Downloaded:</b> {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"<b>Platform:</b> {platform_display}",
]
# Add source (skip for forums since source becomes the platform name)
if source and platform.lower() != 'forums':
message_parts.append(f"<b>Source:</b> {source}")
message_parts.append(f"\n<b>Error:</b> {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()}")