Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

View File

@@ -0,0 +1,634 @@
#!/usr/bin/env python3
"""
Dependency Updater - Automatically updates critical dependencies
Only runs in scheduler mode, once per day
Version Compatibility:
- bcrypt <5.0 required for passlib 1.7.4 compatibility
- passlib 1.7.4 requires bcrypt 4.x (not 5.x)
- uvicorn <0.35.0 required (0.40.0+ has breaking loop_factory changes)
- Pinned packages are skipped during auto-updates to prevent incompatibilities
"""
import json
import subprocess
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict
from modules.universal_logger import get_logger
class DependencyUpdater:
"""Manages automatic updates for critical dependencies"""
def __init__(self,
state_file: str = "/opt/media-downloader/database/dependency_updates.json",
config: dict = None,
pushover_notifier = None,
scheduler_mode: bool = False):
"""
Initialize dependency updater
Args:
state_file: Path to JSON file storing update state
config: Configuration dict from settings.json
pushover_notifier: Instance of PushoverNotifier for alerts
scheduler_mode: Only run updates when True (scheduler mode)
"""
self.state_file = Path(state_file)
self.state_file.parent.mkdir(parents=True, exist_ok=True)
self.pushover = pushover_notifier
self.scheduler_mode = scheduler_mode
# Derive venv paths from module location (more portable than hardcoded path)
import sys
self._base_dir = Path(__file__).parent.parent
self._venv_pip = self._base_dir / 'venv' / 'bin' / 'pip'
self._venv_python = self._base_dir / 'venv' / 'bin' / 'python'
# Fallback to sys.executable's directory if venv not found
if not self._venv_pip.exists():
self._venv_pip = Path(sys.executable).parent / 'pip'
if not self._venv_python.exists():
self._venv_python = Path(sys.executable)
# Default configuration
self.config = {
'enabled': True,
'check_interval_hours': 24,
'auto_install': True,
'components': {
'flaresolverr': {
'enabled': True,
'notify_on_update': True
},
'playwright': {
'enabled': True,
'notify_on_update': False
},
'yt_dlp': {
'enabled': True,
'notify_on_update': False
},
'python_packages': {
'enabled': True,
'notify_on_update': True,
'packages': [
# Core API framework
'fastapi',
'uvicorn',
'pydantic',
'python-jose',
'passlib',
'slowapi',
'starlette',
'python-multipart',
'websockets',
# Security & Auth
'bcrypt',
'cryptography',
'certifi',
'2captcha-python',
'duo-universal',
# Image processing
'pillow',
'numpy',
# Face recognition
'insightface',
'onnxruntime',
'deepface',
'tensorflow',
'face-recognition',
'dlib',
# Web scraping & downloads
'requests',
'beautifulsoup4',
'selenium',
'playwright',
'playwright-stealth',
'instaloader',
'yt-dlp',
'curl-cffi',
'gallery-dl',
# Database
'psycopg2-binary',
# Utilities
'python-dotenv',
'python-dateutil',
'pyotp',
'click',
'attrs',
'charset-normalizer',
'idna',
'websocket-client',
'trio',
'typing_extensions'
]
}
},
'pushover': {
'enabled': True,
'priority': -1,
'sound': 'magic'
}
}
# Merge user config
if config:
self._deep_update(self.config, config)
# Load or initialize state
self.state = self._load_state()
# Setup logging
self.logger = get_logger('DependencyUpdater')
# Known version incompatibilities and constraints
# Format: package_name: [constraints, incompatible_with, reason]
self.version_constraints = {
'bcrypt': {
'constraint': '<5.0',
'reason': 'bcrypt 5.x is incompatible with passlib 1.7.4',
'incompatible_with': ['passlib>=1.7.4,<2.0']
},
'passlib': {
'constraint': '>=1.7.4,<2.0',
'reason': 'passlib 1.7.4 requires bcrypt <5.0',
'requires': ['bcrypt>=4.0.0,<5.0']
},
'uvicorn': {
'constraint': '<0.35.0',
'reason': 'uvicorn 0.40.0+ has breaking changes with loop_factory parameter that crashes on startup',
'known_working': '0.34.0'
}
}
# Packages that should not be auto-updated
self.pinned_packages = {
'bcrypt': 'Version constrained for passlib compatibility',
'passlib': 'Version constrained for bcrypt compatibility',
'uvicorn': 'Version 0.40.0+ has breaking changes with loop_factory parameter'
}
def _deep_update(self, base: dict, update: dict):
"""Deep update dict (recursive merge)"""
for key, value in update.items():
if isinstance(value, dict) and key in base and isinstance(base[key], dict):
self._deep_update(base[key], value)
else:
base[key] = value
def _load_state(self) -> Dict:
"""Load update state from file"""
if self.state_file.exists():
try:
with open(self.state_file, 'r') as f:
return json.load(f)
except Exception as e:
self.logger.error(f"Failed to load update state: {e}")
# Initialize empty state
return {
'last_check': None,
'components': {}
}
def _save_state(self):
"""Save update state to file"""
try:
with open(self.state_file, 'w') as f:
json.dump(self.state, f, indent=2, default=str)
except Exception as e:
self.logger.error(f"Failed to save update state: {e}")
def _should_check_updates(self, force: bool = False) -> bool:
"""Check if enough time has passed since last update check
Args:
force: If True, bypass all checks and return True
Returns:
True if updates should be checked, False otherwise
"""
if force:
return True
if not self.config.get('enabled', True):
return False
# Allow manual checks even outside scheduler mode
if not self.scheduler_mode:
# In non-scheduler mode, only proceed if explicitly called
# This allows manual force_update_check() to work
return False
last_check = self.state.get('last_check')
if not last_check:
return True
try:
last_check_time = datetime.fromisoformat(last_check)
interval_hours = self.config.get('check_interval_hours', 24)
return datetime.now() - last_check_time > timedelta(hours=interval_hours)
except Exception:
return True
def check_and_update_all(self, force: bool = False) -> Dict[str, bool]:
"""
Check and update all enabled components
Args:
force: If True, bypass interval checks and update immediately
Returns:
Dict mapping component name to update success status
"""
if not self._should_check_updates(force=force):
return {}
# Check if auto_install is enabled (default: True)
auto_install = self.config.get('auto_install', True)
if auto_install:
self.logger.info("Checking for dependency updates...")
else:
self.logger.info("Checking for dependency updates (auto_install disabled - check only)...")
return {} # Skip updates if auto_install is disabled
results = {}
# Update last check timestamp
self.state['last_check'] = datetime.now().isoformat()
self._save_state()
# Check each component
components = self.config.get('components', {})
if components.get('flaresolverr', {}).get('enabled', True):
results['flaresolverr'] = self._update_flaresolverr()
if components.get('playwright', {}).get('enabled', True):
results['playwright'] = self._update_playwright()
if components.get('yt_dlp', {}).get('enabled', True):
results['yt_dlp'] = self._update_yt_dlp()
if components.get('python_packages', {}).get('enabled', True):
results['python_packages'] = self._update_python_packages()
# Send summary notification if any updates installed
if any(results.values()) and self.pushover:
self._send_update_notification(results)
return results
def _update_flaresolverr(self) -> bool:
"""
Update FlareSolverr Docker container
Returns:
True if update was installed, False otherwise
"""
try:
self.logger.info("Checking FlareSolverr for updates...")
# Pull latest image
result = subprocess.run(
['docker', 'pull', 'ghcr.io/flaresolverr/flaresolverr:latest'],
capture_output=True,
text=True,
timeout=300
)
if result.returncode != 0:
self.logger.error(f"Failed to pull FlareSolverr image: {result.stderr}")
return False
# Check if image was updated (look for "Downloaded newer image" or "Image is up to date")
output = result.stdout + result.stderr
updated = "Downloaded newer image" in output or "pulling from" in output.lower()
if not updated:
self.logger.info("FlareSolverr is already up to date")
self._update_component_state('flaresolverr', False)
return False
# Image was updated - restart container if running
self.logger.info("FlareSolverr image updated, restarting container...")
# Check if container exists
check_result = subprocess.run(
['docker', 'ps', '-a', '--filter', 'name=flaresolverr', '--format', '{{.Names}}'],
capture_output=True,
text=True
)
if 'flaresolverr' in check_result.stdout:
# Stop and remove old container
subprocess.run(['docker', 'stop', 'flaresolverr'], capture_output=True)
subprocess.run(['docker', 'rm', 'flaresolverr'], capture_output=True)
# Start new container with latest image
subprocess.run([
'docker', 'run', '-d',
'--name', 'flaresolverr',
'-p', '8191:8191',
'-e', 'LOG_LEVEL=info',
'--restart', 'unless-stopped',
'ghcr.io/flaresolverr/flaresolverr:latest'
], capture_output=True)
self.logger.info("✓ FlareSolverr updated and restarted successfully")
else:
self.logger.info("✓ FlareSolverr image updated (container not running)")
self._update_component_state('flaresolverr', True)
return True
except subprocess.TimeoutExpired:
self.logger.error("FlareSolverr update timed out")
return False
except Exception as e:
self.logger.error(f"FlareSolverr update error: {e}")
return False
def _update_playwright(self) -> bool:
"""
Update Playwright browsers (Chromium and Firefox)
Returns:
True if update was installed, False otherwise
"""
try:
self.logger.info("Checking Playwright browsers for updates...")
# Use venv python for playwright commands
venv_python = str(self._venv_python)
# Update Chromium
result_chromium = subprocess.run(
[venv_python, '-m', 'playwright', 'install', 'chromium'],
capture_output=True,
text=True,
timeout=600,
cwd=str(self._base_dir)
)
# Update Firefox
result_firefox = subprocess.run(
[venv_python, '-m', 'playwright', 'install', 'firefox'],
capture_output=True,
text=True,
timeout=600,
cwd=str(self._base_dir)
)
success = result_chromium.returncode == 0 and result_firefox.returncode == 0
if success:
# Check if anything was actually updated
output = result_chromium.stdout + result_firefox.stdout
updated = "Downloading" in output or "Installing" in output
if updated:
self.logger.info("✓ Playwright browsers updated successfully")
self._update_component_state('playwright', True)
return True
else:
self.logger.info("Playwright browsers already up to date")
self._update_component_state('playwright', False)
return False
else:
self.logger.error("Failed to update Playwright browsers")
return False
except subprocess.TimeoutExpired:
self.logger.error("Playwright update timed out")
return False
except Exception as e:
self.logger.error(f"Playwright update error: {e}")
return False
def _update_yt_dlp(self) -> bool:
"""
Update yt-dlp (critical for TikTok downloads)
Returns:
True if update was installed, False otherwise
"""
try:
self.logger.info("Checking yt-dlp for updates...")
# Use venv pip (derived from module location for portability)
venv_pip = str(self._venv_pip)
# Try updating via pip
result = subprocess.run(
[venv_pip, 'install', '--upgrade', 'yt-dlp'],
capture_output=True,
text=True,
timeout=120
)
if result.returncode != 0:
self.logger.error(f"Failed to update yt-dlp: {result.stderr}")
return False
# Check if update was installed
output = result.stdout + result.stderr
updated = "Successfully installed" in output and "yt-dlp" in output
if updated:
self.logger.info("✓ yt-dlp updated successfully")
self._update_component_state('yt_dlp', True)
return True
else:
self.logger.info("yt-dlp already up to date")
self._update_component_state('yt_dlp', False)
return False
except subprocess.TimeoutExpired:
self.logger.error("yt-dlp update timed out")
return False
except Exception as e:
self.logger.error(f"yt-dlp update error: {e}")
return False
def _update_python_packages(self) -> bool:
"""
Update Python packages (FastAPI, Uvicorn, Pydantic, etc.)
Returns:
True if any updates were installed, False otherwise
"""
try:
self.logger.info("Checking Python packages for updates...")
# Get list of packages to update
packages = self.config.get('components', {}).get('python_packages', {}).get('packages', [])
if not packages:
self.logger.info("No Python packages configured for updates")
return False
# Use venv pip (derived from module location for portability)
venv_pip = str(self._venv_pip)
updated_packages = []
for package in packages:
try:
# Check if package is pinned (should not be auto-updated)
if package in self.pinned_packages:
self.logger.info(f"⚠ Skipping {package}: {self.pinned_packages[package]}")
continue
# Check for version constraints
if package in self.version_constraints:
constraint_info = self.version_constraints[package]
constraint = constraint_info.get('constraint', '')
reason = constraint_info.get('reason', 'Version constraint')
if constraint:
# Install with constraint instead of --upgrade
package_spec = f"{package}{constraint}"
self.logger.info(f"📌 {package}: Applying constraint {constraint} ({reason})")
result = subprocess.run(
[venv_pip, 'install', package_spec],
capture_output=True,
text=True,
timeout=120
)
else:
# No constraint, normal upgrade
result = subprocess.run(
[venv_pip, 'install', '--upgrade', package],
capture_output=True,
text=True,
timeout=120
)
else:
# Update package normally
result = subprocess.run(
[venv_pip, 'install', '--upgrade', package],
capture_output=True,
text=True,
timeout=120
)
if result.returncode == 0:
output = result.stdout + result.stderr
# Check if package was actually updated
if "Successfully installed" in output and package in output:
updated_packages.append(package)
self.logger.info(f"{package} updated")
elif "Requirement already satisfied" in output:
self.logger.debug(f" {package} already up to date")
else:
self.logger.debug(f" {package} checked")
else:
self.logger.warning(f"Failed to update {package}: {result.stderr}")
except subprocess.TimeoutExpired:
self.logger.warning(f"{package} update timed out")
except Exception as e:
self.logger.warning(f"Error updating {package}: {e}")
if updated_packages:
self.logger.info(f"✓ Updated {len(updated_packages)} Python package(s): {', '.join(updated_packages)}")
self._update_component_state('python_packages', True)
# Store list of updated packages in state
if 'components' not in self.state:
self.state['components'] = {}
if 'python_packages' not in self.state['components']:
self.state['components']['python_packages'] = {}
self.state['components']['python_packages']['updated_packages'] = updated_packages
self._save_state()
return True
else:
self.logger.info("All Python packages already up to date")
self._update_component_state('python_packages', False)
return False
except Exception as e:
self.logger.error(f"Python packages update error: {e}")
return False
def _update_component_state(self, component: str, updated: bool):
"""Update component state in JSON"""
if 'components' not in self.state:
self.state['components'] = {}
if component not in self.state['components']:
self.state['components'][component] = {}
self.state['components'][component]['last_update'] = datetime.now().isoformat() if updated else self.state['components'][component].get('last_update')
self.state['components'][component]['last_check'] = datetime.now().isoformat()
self.state['components'][component]['status'] = 'updated' if updated else 'current'
self._save_state()
def _send_update_notification(self, results: Dict[str, bool]):
"""Send Pushover notification about installed updates"""
if not self.config.get('pushover', {}).get('enabled', True):
return
# Build list of updated components
updated_components = [name for name, updated in results.items() if updated]
if not updated_components:
return
# Check which components should send notifications
notify_components = []
for component in updated_components:
component_config = self.config.get('components', {}).get(component, {})
if component_config.get('notify_on_update', True):
notify_components.append(component)
if not notify_components:
return
# Format component names
component_map = {
'flaresolverr': 'FlareSolverr',
'playwright': 'Playwright Browsers',
'yt_dlp': 'yt-dlp',
'python_packages': 'Python Packages'
}
formatted_names = [component_map.get(c, c) for c in notify_components]
title = "🔄 Dependencies Updated"
if len(formatted_names) == 1:
message = f"{formatted_names[0]} has been updated to the latest version."
else:
message = f"The following components have been updated:\n\n"
for name in formatted_names:
message += f"{name}\n"
message += f"\nUpdated at: {datetime.now().strftime('%b %d, %I:%M %p')}"
try:
priority = self.config.get('pushover', {}).get('priority', -1)
sound = self.config.get('pushover', {}).get('sound', 'magic')
self.pushover.send_notification(
title=title,
message=message,
priority=priority,
sound=sound
)
self.logger.info(f"Sent update notification for: {', '.join(formatted_names)}")
except Exception as e:
self.logger.error(f"Failed to send update notification: {e}")
def get_update_status(self) -> Dict:
"""Get current update status for all components"""
return self.state.copy()
def force_update_check(self) -> Dict[str, bool]:
"""Force immediate update check regardless of interval or scheduler mode"""
return self.check_and_update_all(force=True)