962 lines
38 KiB
Python
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()}")
|