""" Platforms Router Handles platform management operations: - List all platforms and their status - Trigger manual downloads for platforms - Instagram-specific post downloads """ import asyncio import re import shutil import tempfile from datetime import datetime from pathlib import Path from typing import Dict, List, Optional from fastapi import APIRouter, BackgroundTasks, Body, Depends, HTTPException, Request from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_app_state from ..core.exceptions import handle_exceptions, ValidationError from ..core.responses import now_iso8601 from modules.universal_logger import get_logger logger = get_logger('API') router = APIRouter(prefix="/api", tags=["Platforms"]) limiter = Limiter(key_func=get_remote_address) # Semaphore to limit concurrent Instagram post downloads to 1 _instagram_post_semaphore = asyncio.Semaphore(1) _instagram_post_queue: List[str] = [] # Track queued posts for status # ============================================================================ # PYDANTIC MODELS # ============================================================================ class TriggerRequest(BaseModel): username: Optional[str] = None content_types: Optional[List[str]] = None class InstagramPostRequest(BaseModel): post_id: str username: Optional[str] = None # ============================================================================ # DOWNLOAD CACHE UTILITIES # ============================================================================ def invalidate_download_cache(): """Invalidate download-related caches after downloads complete.""" from ..core.dependencies import get_app_state app_state = get_app_state() if hasattr(app_state, 'cache') and app_state.cache: try: app_state.cache.invalidate_pattern('downloads:*') app_state.cache.invalidate_pattern('stats:*') app_state.cache.invalidate_pattern('gallery:*') except Exception: pass # ============================================================================ # PLATFORM STATUS ENDPOINTS # ============================================================================ @router.get("/platforms/running") @limiter.limit("100/minute") @handle_exceptions async def get_running_platforms( request: Request, current_user: Dict = Depends(get_current_user) ): """Get list of currently running platform downloads""" app_state = get_app_state() running = [] if hasattr(app_state, 'running_platform_downloads'): for platform, data in app_state.running_platform_downloads.items(): running.append({ 'platform': platform, 'started_at': data.get('started_at'), 'session_id': data.get('session_id') }) return { "platforms": running, "count": len(running) } @router.post("/platforms/{platform}/stop") @limiter.limit("10/minute") @handle_exceptions async def stop_platform_download( request: Request, platform: str, current_user: Dict = Depends(get_current_user) ): """Stop a running platform download""" app_state = get_app_state() if not hasattr(app_state, 'running_platform_downloads'): raise HTTPException(status_code=404, detail="No running downloads found") if platform not in app_state.running_platform_downloads: raise HTTPException(status_code=404, detail=f"Platform {platform} is not running") download_data = app_state.running_platform_downloads[platform] process = download_data.get('process') session_id = download_data.get('session_id') if process: try: import subprocess # Get the process ID pid = process.pid logger.info(f"Stopping {platform} download (PID: {pid})", module="Platforms") # Kill all child processes first using pkill try: result = subprocess.run(['pkill', '-9', '-P', str(pid)], capture_output=True, timeout=2) logger.info(f"Killed child processes of PID {pid}", module="Platforms") except subprocess.TimeoutExpired: logger.warning(f"pkill timed out for PID {pid}, continuing", module="Platforms") except Exception as pkill_error: logger.warning(f"pkill failed for PID {pid}: {pkill_error}, continuing", module="Platforms") # Give processes time to die await asyncio.sleep(0.5) # Then kill the parent process try: process.kill() await asyncio.wait_for(process.wait(), timeout=5.0) logger.info(f"Stopped {platform} download successfully", module="Platforms") except asyncio.TimeoutError: logger.warning(f"Process {pid} did not terminate in time", module="Platforms") except ProcessLookupError: logger.info(f"Process {pid} already terminated", module="Platforms") except Exception as e: error_msg = str(e) if str(e) else repr(e) logger.error(f"Error stopping {platform} download: {error_msg}", module="Platforms", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to stop download: {error_msg}") # Remove from tracking app_state.running_platform_downloads.pop(platform, None) # Clean up scraper session if it exists if session_id and hasattr(app_state, 'active_scraper_sessions'): app_state.active_scraper_sessions.pop(session_id, None) # Emit scraper_completed event to update frontend if session_id and hasattr(app_state, 'scraper_event_emitter') and app_state.scraper_event_emitter: app_state.scraper_event_emitter.emit_scraper_completed( session_id=session_id, stats={ 'total_downloaded': 0, 'moved': 0, 'review': 0, 'duplicates': 0, 'failed': 0 } ) return { "success": True, "platform": platform, "message": f"{platform} download stopped" } # ============================================================================ # SCRAPER MONITOR ENDPOINTS # ============================================================================ @router.get("/scraper-sessions/active") @limiter.limit("100/minute") @handle_exceptions async def get_active_scraper_sessions( request: Request, current_user: Dict = Depends(get_current_user) ): """Get currently active scraping sessions for the scraping monitor page""" app_state = get_app_state() # Return active sessions from app_state sessions = app_state.active_scraper_sessions if hasattr(app_state, 'active_scraper_sessions') else {} return { "sessions": list(sessions.values()), "count": len(sessions) } # ============================================================================ # PLATFORM LIST ENDPOINT # ============================================================================ @router.get("/platforms") @limiter.limit("100/minute") @handle_exceptions async def get_platforms( request: Request, current_user: Dict = Depends(get_current_user) ): """Get list of all platforms and their status.""" app_state = get_app_state() config = app_state.settings.get_all() if app_state.settings else app_state.config platforms = [] display_names = { 'instagram_unified': 'Instagram', 'snapchat': 'Snapchat', 'snapchat_client': 'Snapchat', 'tiktok': 'TikTok', 'forums': 'Forums', 'coppermine': 'Coppermine', } hidden_modules = config.get('hidden_modules', []) for platform_name in ['instagram_unified', 'snapchat', 'snapchat_client', 'tiktok', 'forums', 'coppermine']: if platform_name in hidden_modules: continue platform_config = config.get(platform_name, {}) enabled = platform_config.get('enabled', False) check_interval = None if platform_name == 'instagram_unified': accounts = platform_config.get('accounts', []) if accounts: check_interval = accounts[0].get('check_interval_hours') elif platform_name == 'tiktok': accounts = platform_config.get('accounts', []) if accounts: check_interval = accounts[0].get('check_interval_hours') elif platform_name == 'forums': configs = platform_config.get('configs', []) if configs: check_interval = configs[0].get('check_interval_hours') elif platform_name == 'coppermine': check_interval = platform_config.get('check_interval_hours') else: check_interval = platform_config.get('check_interval_hours') account_count = 0 if platform_name == 'instagram_unified': account_count = len(platform_config.get('accounts', [])) elif platform_name in ['snapchat', 'snapchat_client']: account_count = len(platform_config.get('usernames', [])) elif platform_name == 'tiktok': account_count = len(platform_config.get('accounts', [])) elif platform_name == 'forums': account_count = len(platform_config.get('configs', [])) elif platform_name == 'coppermine': account_count = len(platform_config.get('galleries', [])) platforms.append({ "name": platform_name, "type": "scheduled", "enabled": enabled, "display_name": display_names.get(platform_name, platform_name.title()), "check_interval_hours": check_interval, "account_count": account_count, "config": platform_config }) logger.info(f"Returning {len(platforms)} scheduled platforms", module="Platforms") return platforms # ============================================================================ # TRIGGER PLATFORM DOWNLOAD # ============================================================================ @router.post("/platforms/{platform}/trigger") @limiter.limit("10/minute") @handle_exceptions async def trigger_platform_download( request: Request, platform: str, trigger_data: TriggerRequest, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user) ): """Manually trigger a download for a platform.""" valid_platforms = ['instagram_unified', 'fastdl', 'imginn', 'imginn_api', 'instagram_client', 'toolzu', 'snapchat', 'snapchat_client', 'tiktok', 'forums', 'coppermine'] if platform not in valid_platforms: raise ValidationError(f"Invalid platform: {platform}") app_state = get_app_state() task_id = f"{platform}_{datetime.now().timestamp()}" async def run_download(): try: session_id = f"{platform}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" # Get list of accounts/forums to process accounts_to_process = [] # For forums, get ALL enabled forum names if platform == 'forums': forums_config = app_state.settings.get('forums', {}) for forum_cfg in forums_config.get('configs', []): if forum_cfg.get('enabled', False): accounts_to_process.append(forum_cfg.get('name', 'Unknown')) # For Coppermine, get ALL galleries elif platform == 'coppermine': coppermine_config = app_state.settings.get('coppermine', {}) for gallery in coppermine_config.get('galleries', []): gallery_name = gallery.get('name', 'Unknown') accounts_to_process.append(gallery_name) # For Instagram unified, get all accounts from unified config elif platform == 'instagram_unified': unified_config = app_state.settings.get('instagram_unified', {}) for acc in unified_config.get('accounts', []): if acc.get('username'): accounts_to_process.append(acc['username']) # For other platforms, get ALL usernames elif platform in ['fastdl', 'imginn', 'imginn_api', 'instagram_client', 'toolzu', 'snapchat']: platform_config = app_state.settings.get(platform, {}) accounts_to_process = platform_config.get('usernames', []) # For imginn/imginn_api/fastdl/instagram_client, also include phrase search usernames if enabled if platform in ['imginn', 'imginn_api', 'fastdl', 'instagram_client']: phrase_config = platform_config.get('phrase_search', {}) if phrase_config.get('enabled'): phrase_usernames = phrase_config.get('usernames', []) # Add phrase search users to the list accounts_to_process.extend(phrase_usernames) # For TikTok, get ALL accounts elif platform == 'tiktok': tiktok_config = app_state.settings.get('tiktok', {}) accounts = tiktok_config.get('accounts', []) for acc in accounts: if acc.get('enabled', True): accounts_to_process.append(acc.get('username', 'Unknown')) # Emit ONE scraper_started event with the complete list # Map Instagram downloaders to "Instagram" for display display_platform = platform if platform in ['instagram_unified', 'fastdl', 'imginn', 'imginn_api', 'instagram_client', 'toolzu', 'instaloader']: display_platform = 'Instagram' if app_state.scraper_event_emitter: app_state.scraper_event_emitter.emit_scraper_started( session_id=session_id, platform=display_platform, account='all', # Match scheduler format content_type="auto", estimated_count=len(accounts_to_process), accounts_list=accounts_to_process # Pass the full list ) cmd = ["media-downloader", "--platform", platform] process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) # Track running process in app_state if hasattr(app_state, 'running_platform_downloads'): app_state.running_platform_downloads[platform] = { 'process': process, 'started_at': datetime.now().isoformat(), 'session_id': session_id } manager = getattr(app_state, 'websocket_manager', None) # Track progress by parsing log output # Note: Universal logger's console handler writes to stderr, not stdout current_account = None processed_count = 0 # Track how many accounts we've processed sequentially completed_accounts = [] # Track accounts that have finished processing if manager: async for line in process.stderr: line_text = line.decode().strip() # Broadcast log message await manager.broadcast({ "type": "log", "level": "info", "message": line_text, "platform": display_platform }) # Parse log to detect current forum/account being processed if app_state.scraper_event_emitter and accounts_to_process: import re # Detect forum processing - match exact format: "Processing forum: ForumName" if 'Processing forum:' in line_text: forum_match = re.search(r'Processing forum:\s+(.+)', line_text) if forum_match: detected_forum = forum_match.group(1).strip() logger.debug(f"Detected forum from log: '{detected_forum}'", module="ScrapingMonitor") # Find matching account in our list for account in accounts_to_process: logger.debug(f"Comparing '{detected_forum.lower()}' with '{account.lower()}'", module="ScrapingMonitor") if account.lower() in detected_forum.lower() or detected_forum.lower() in account.lower(): if current_account != account: # Mark previous account as completed if current_account and current_account not in completed_accounts: completed_accounts.append(current_account) current_account = account processed_count += 1 logger.info(f"Forum progress: Now processing {account} ({processed_count}/{len(accounts_to_process)})", module="ScrapingMonitor") app_state.scraper_event_emitter.emit_scraper_progress( session_id=session_id, status=f"Checking forum thread: {account}", current=processed_count, total=len(accounts_to_process), current_account=account, completed_accounts=completed_accounts ) break # Detect Coppermine gallery processing - match exact format: "Processing Coppermine gallery: GalleryName" elif 'Processing Coppermine gallery:' in line_text: gallery_match = re.search(r'Processing Coppermine gallery:\s+(.+)', line_text) if gallery_match: detected_gallery = gallery_match.group(1).strip() # Find matching gallery in our list for account in accounts_to_process: if account.lower() in detected_gallery.lower() or detected_gallery.lower() in account.lower(): if current_account != account: # Mark previous account as completed if current_account and current_account not in completed_accounts: completed_accounts.append(current_account) current_account = account processed_count += 1 logger.info(f"Coppermine progress: Now processing {account} ({processed_count}/{len(accounts_to_process)})", module="ScrapingMonitor") app_state.scraper_event_emitter.emit_scraper_progress( session_id=session_id, status=f"Checking gallery: {account}", current=processed_count, total=len(accounts_to_process), current_account=account, completed_accounts=completed_accounts ) break # Detect TikTok user processing (format: "Downloading TikTok profile: @username") elif 'Downloading TikTok profile: @' in line_text: user_match = re.search(r'Downloading TikTok profile: @([a-zA-Z0-9_.]+)', line_text) if user_match: detected_user = user_match.group(1) # Find matching account in our list for account in accounts_to_process: if account.lower() == detected_user.lower(): if current_account != account: # Mark previous account as completed if current_account and current_account not in completed_accounts: completed_accounts.append(current_account) current_account = account processed_count += 1 logger.info(f"TikTok progress: Now processing {account} ({processed_count}/{len(accounts_to_process)})", module="ScrapingMonitor") app_state.scraper_event_emitter.emit_scraper_progress( session_id=session_id, status=f"Checking videos from @{account}", current=processed_count, total=len(accounts_to_process), current_account=account, completed_accounts=completed_accounts ) break # Detect Snapchat user processing (format: "Navigating to @username on domain") elif 'Navigating to @' in line_text: user_match = re.search(r'Navigating to @([a-zA-Z0-9_.]+)', line_text) if user_match: detected_user = user_match.group(1) # Find matching account in our list for account in accounts_to_process: if account.lower() == detected_user.lower(): if current_account != account: # Mark previous account as completed if current_account and current_account not in completed_accounts: completed_accounts.append(current_account) current_account = account processed_count += 1 logger.info(f"Snapchat progress: Now processing {account} ({processed_count}/{len(accounts_to_process)})", module="ScrapingMonitor") app_state.scraper_event_emitter.emit_scraper_progress( session_id=session_id, status=f"Checking stories from @{account}", current=processed_count, total=len(accounts_to_process), current_account=account, completed_accounts=completed_accounts ) break # Detect Instagram/FastDL user processing (format: "Processing Instagram {content_type} for @username") elif 'Processing Instagram' in line_text and 'for @' in line_text: user_match = re.search(r'Processing Instagram (\w+) for @([a-zA-Z0-9_.]+)', line_text) if user_match: content_type = user_match.group(1) # posts, stories, reels, tagged detected_user = user_match.group(2) # Find matching account in our list for account in accounts_to_process: if account.lower() == detected_user.lower(): if current_account != account: # Mark previous account as completed if current_account and current_account not in completed_accounts: completed_accounts.append(current_account) current_account = account processed_count += 1 logger.info(f"FastDL/Instagram progress: Now processing {account} ({processed_count}/{len(accounts_to_process)})", module="ScrapingMonitor") # Always update status with current content type status_msg = f"Checking {content_type} from @{account}" app_state.scraper_event_emitter.emit_scraper_progress( session_id=session_id, status=status_msg, current=processed_count, total=len(accounts_to_process), current_account=account, completed_accounts=completed_accounts ) break # Detect phrase search user processing (format: "Searching username for phrase matches") elif 'for phrase matches' in line_text: user_match = re.search(r'Searching ([a-zA-Z0-9_.]+) for phrase matches', line_text) if user_match: detected_user = user_match.group(1) # Find matching account in our list for account in accounts_to_process: if account.lower() == detected_user.lower(): if current_account != account: # Mark previous account as completed if current_account and current_account not in completed_accounts: completed_accounts.append(current_account) current_account = account processed_count += 1 logger.info(f"Phrase search progress: Now processing {account} ({processed_count}/{len(accounts_to_process)})", module="ScrapingMonitor") app_state.scraper_event_emitter.emit_scraper_progress( session_id=session_id, status=f"Searching phrases in: {account}", current=processed_count, total=len(accounts_to_process), current_account=account, completed_accounts=completed_accounts ) break await process.wait() invalidate_download_cache() # Remove from running downloads if hasattr(app_state, 'running_platform_downloads'): app_state.running_platform_downloads.pop(platform, None) # Emit scraper_completed event once for the whole platform if app_state.scraper_event_emitter: app_state.scraper_event_emitter.emit_scraper_completed( session_id=session_id, stats={ 'total_downloaded': 0, # CLI doesn't report this 'moved': 0, 'review': 0, 'duplicates': 0, 'failed': 0 if process.returncode == 0 else 1 } ) if manager: await manager.broadcast({ "type": "download_completed", "platform": display_platform, "username": trigger_data.username or "all", "exit_code": process.returncode, "timestamp": now_iso8601(), "from_platform_page": True }) except Exception as e: # Remove from running downloads if hasattr(app_state, 'running_platform_downloads'): app_state.running_platform_downloads.pop(platform, None) # Emit scraper_completed event with error if app_state.scraper_event_emitter: app_state.scraper_event_emitter.emit_scraper_completed( session_id=session_id, stats={ 'total_downloaded': 0, 'moved': 0, 'review': 0, 'duplicates': 0, 'failed': 1 } ) manager = getattr(app_state, 'websocket_manager', None) if manager: await manager.broadcast({ "type": "download_error", "platform": platform, "error": str(e), "timestamp": now_iso8601() }) background_tasks.add_task(run_download) return { "success": True, "task_id": task_id, "platform": platform, "message": f"Download triggered for {platform}" } # ============================================================================ # INSTAGRAM POST DOWNLOAD # ============================================================================ @router.post("/instagram/download-post") @limiter.limit("20/minute") @handle_exceptions async def download_instagram_post( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user), post_id: str = Body(..., description="Instagram post shortcode or full URL"), username: str = Body(None, description="Username (optional)") ): """Download a specific Instagram post using ImgInn and process through move manager.""" app_state = get_app_state() # Extract shortcode from URL if full URL provided shortcode = post_id if 'instagram.com' in post_id or 'imginn.com' in post_id: match = re.search(r'/p/([A-Za-z0-9_-]+)', post_id) if match: shortcode = match.group(1) else: raise ValidationError("Could not extract post ID from URL") # Check if already downloaded with app_state.db.get_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT id, filename, file_path FROM downloads WHERE media_id = ? AND platform = 'instagram' ''', (shortcode,)) existing = cursor.fetchone() if existing and existing['file_path']: if Path(existing['file_path']).exists(): return { "success": False, "message": f"Post {shortcode} already downloaded", "filename": existing['filename'], "file_path": existing['file_path'] } task_id = f"instagram_post_{shortcode}_{datetime.now().timestamp()}" async def run_post_download(): # Add to queue tracking _instagram_post_queue.append(shortcode) queue_position = len(_instagram_post_queue) # Wait for semaphore (only 1 download at a time) async with _instagram_post_semaphore: try: from modules.activity_status import get_activity_manager activity_manager = get_activity_manager(app_state.db) activity_manager.start_activity( task_id=task_id, platform='instagram', account=username if username else f'post {shortcode}', status='Downloading post' ) activity_manager.update_status(f"Fetching post {shortcode}...") manager = getattr(app_state, 'websocket_manager', None) if manager: await manager.broadcast({ "type": "download_started", "platform": "instagram", "username": username or f"post/{shortcode}", "post_id": shortcode, "timestamp": now_iso8601() }) import concurrent.futures def do_download(): from modules.imginn_module import ImgInnDownloader from modules.move_module import MoveManager imginn_config = app_state.settings.get('imginn', {}) posts_config = imginn_config.get('posts', {}) dest_path = posts_config.get('destination_path') if not dest_path: dest_path = "/opt/immich/md/social media/instagram/posts" logger.warning("ImgInn posts destination not configured, using default", module="Instagram") dest_base = Path(dest_path) dest_base.mkdir(parents=True, exist_ok=True) temp_dir = Path(tempfile.mkdtemp(prefix='instagram_post_')) try: from datetime import datetime downloader = ImgInnDownloader(unified_db=app_state.db) post_url = f"https://imginn.com/p/{shortcode}/" target_username = username or 'unknown' # Generate session ID for tracking session_id = f"imginn_{target_username}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" # Emit scraper_started event if app_state.scraper_event_emitter: app_state.scraper_event_emitter.emit_scraper_started( session_id=session_id, platform='instagram', account=target_username, content_type='post', estimated_count=1 ) downloaded = downloader.download_posts( username=target_username, specific_post_url=post_url, output_dir=temp_dir, days_back=365, max_posts=1 ) if not downloaded: return {"success": False, "message": "No files downloaded"} if target_username == 'unknown' and downloaded: first_file = Path(downloaded[0]) parts = first_file.stem.split('_') if parts: target_username = parts[0] move_manager = MoveManager( unified_db=app_state.db, event_emitter=app_state.scraper_event_emitter ) # Set session context for real-time monitoring (use session_id from above) move_manager.set_session_context( platform='instagram', account=target_username, session_id=session_id ) moved_files = [] # Create username subdirectory user_dest = dest_base / target_username user_dest.mkdir(parents=True, exist_ok=True) for file_path in downloaded: src = Path(file_path) if src.exists(): dest = user_dest / src.name move_manager.start_batch( platform='instagram', source=target_username, content_type='post' ) success = move_manager.move_file(src, dest) if success: moved_files.append(str(dest)) move_manager.end_batch() # Emit scraper_completed event if app_state.scraper_event_emitter: app_state.scraper_event_emitter.emit_scraper_completed( session_id=session_id, stats={ 'total_downloaded': len(downloaded), 'moved': len(moved_files), 'review': 0, # Would need to track separately 'duplicates': 0, # Would need to track separately 'failed': len(downloaded) - len(moved_files) } ) return { "success": True, "message": f"Downloaded and processed {len(moved_files)} file(s)", "files": moved_files, "username": target_username } finally: if temp_dir.exists(): shutil.rmtree(temp_dir, ignore_errors=True) # Use a fresh ThreadPoolExecutor to avoid Playwright sync API issues # Playwright detects if there's an event loop in the thread and errors loop = asyncio.get_event_loop() with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: result = await loop.run_in_executor(executor, do_download) activity_manager.stop_activity() invalidate_download_cache() if manager: # Get username from result if available completed_username = result.get('username') if result else None await manager.broadcast({ "type": "download_completed", "platform": "instagram", "username": completed_username or username or f"post/{shortcode}", "post_id": shortcode, "result": result, "timestamp": now_iso8601() }) return result except Exception as e: logger.error(f"Failed to download Instagram post {shortcode}: {e}") import traceback traceback.print_exc() try: activity_manager.stop_activity() except Exception: pass manager = getattr(app_state, 'websocket_manager', None) if manager: await manager.broadcast({ "type": "download_error", "platform": "instagram", "username": username or f"post/{shortcode}", "post_id": shortcode, "error": str(e), "timestamp": now_iso8601() }) finally: # Remove from queue tracking if shortcode in _instagram_post_queue: _instagram_post_queue.remove(shortcode) background_tasks.add_task(run_post_download) return { "success": True, "task_id": task_id, "post_id": shortcode, "message": f"Download started for post {shortcode}" } # ============================================================================ # YOUTUBE CHANNEL MONITORS - Global Settings + Channel List # ============================================================================ class YouTubeMonitorSettingsUpdate(BaseModel): """Update global YouTube monitor settings.""" phrases: Optional[List[str]] = None check_interval_hours: Optional[int] = None quality: Optional[str] = None enabled: Optional[bool] = None auto_start_queue: Optional[bool] = None notifications_enabled: Optional[bool] = None auto_pause_threshold_months: Optional[int] = None paused_check_interval_days: Optional[int] = None max_results_per_phrase: Optional[int] = None class YouTubeChannelCreate(BaseModel): """Add a YouTube channel to monitor.""" channel_url: str channel_name: Optional[str] = None enabled: bool = True class YouTubeChannelUpdate(BaseModel): """Update a YouTube channel.""" channel_url: Optional[str] = None channel_name: Optional[str] = None enabled: Optional[bool] = None always_active: Optional[bool] = None @router.get("/platforms/youtube-monitors") @limiter.limit("100/minute") @handle_exceptions async def get_youtube_monitors( request: Request, current_user: Dict = Depends(get_current_user) ): """Get YouTube monitor global settings and all channels.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) global_settings = monitor.get_global_settings() channels = monitor.get_all_channels() return { "success": True, "settings": global_settings, "channels": channels, "count": len(channels) } @router.put("/platforms/youtube-monitors/settings") @limiter.limit("30/minute") @handle_exceptions async def update_youtube_monitor_settings( data: YouTubeMonitorSettingsUpdate, request: Request, current_user: Dict = Depends(get_current_user) ): """Update global YouTube monitor settings (phrases, interval, quality).""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) success = monitor.update_global_settings( phrases=data.phrases, check_interval_hours=data.check_interval_hours, quality=data.quality, enabled=data.enabled, auto_start_queue=data.auto_start_queue, notifications_enabled=data.notifications_enabled, auto_pause_threshold_months=data.auto_pause_threshold_months, paused_check_interval_days=data.paused_check_interval_days, max_results_per_phrase=data.max_results_per_phrase ) updated_settings = monitor.get_global_settings() return { "success": success, "settings": updated_settings, "message": "YouTube monitor settings updated successfully" } @router.post("/platforms/youtube-monitors/channels") @limiter.limit("30/minute") @handle_exceptions async def add_youtube_channel( data: YouTubeChannelCreate, request: Request, current_user: Dict = Depends(get_current_user) ): """Add a new YouTube channel to monitor.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings logger.info(f"Adding YouTube channel: {data.channel_url}") if not data.channel_url: logger.error("channel_url is missing") raise ValidationError("channel_url is required") settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) try: channel_id = monitor.add_channel( channel_url=data.channel_url, channel_name=data.channel_name, enabled=data.enabled ) logger.info(f"Successfully added channel {channel_id}: {data.channel_url}") except Exception as e: logger.error(f"Error adding channel: {e}") if "UNIQUE constraint failed" in str(e): raise ValidationError("This channel URL is already being monitored") raise channel = monitor.get_channel(channel_id) return { "success": True, "channel": channel, "message": "YouTube channel added successfully" } @router.put("/platforms/youtube-monitors/channels/{channel_id}") @limiter.limit("30/minute") @handle_exceptions async def update_youtube_channel( channel_id: int, data: YouTubeChannelUpdate, request: Request, current_user: Dict = Depends(get_current_user) ): """Update a YouTube channel.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) existing = monitor.get_channel(channel_id) if not existing: raise HTTPException(status_code=404, detail="Channel not found") updates = {} if data.channel_url is not None: updates['channel_url'] = data.channel_url if data.channel_name is not None: updates['channel_name'] = data.channel_name if data.enabled is not None: updates['enabled'] = data.enabled if updates: monitor.update_channel(channel_id, **updates) channel = monitor.get_channel(channel_id) return { "success": True, "channel": channel, "message": "YouTube channel updated successfully" } @router.delete("/platforms/youtube-monitors/channels/{channel_id}") @limiter.limit("30/minute") @handle_exceptions async def delete_youtube_channel( channel_id: int, request: Request, current_user: Dict = Depends(get_current_user) ): """Delete a YouTube channel from monitoring.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) existing = monitor.get_channel(channel_id) if not existing: raise HTTPException(status_code=404, detail="Channel not found") success = monitor.delete_channel(channel_id) return { "success": success, "message": "YouTube channel deleted successfully" if success else "Failed to delete channel" } @router.post("/platforms/youtube-monitors/check-all") @limiter.limit("5/minute") @handle_exceptions async def check_all_youtube_channels( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user) ): """Trigger an immediate check for all enabled YouTube channels.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from modules.activity_status import get_activity_manager from ..core.config import Settings settings = Settings() app_state = get_app_state() activity_manager = get_activity_manager(app_state.db) monitor = YouTubeChannelMonitor(str(settings.DB_PATH), activity_manager) global_settings = monitor.get_global_settings() if not global_settings.get('phrases'): raise ValidationError("No match phrases configured. Please add phrases in settings first.") channels = monitor.get_enabled_channels() if not channels: raise ValidationError("No enabled channels to check") async def run_check(): try: videos_found = await monitor.check_all_now() logger.info(f"YouTube monitor check-all complete: {videos_found} videos found") except Exception as e: logger.error(f"Error in YouTube monitor check-all: {e}") background_tasks.add_task(run_check) return { "success": True, "message": f"Checking {len(channels)} channels...", "channels_count": len(channels) } @router.post("/platforms/youtube-monitors/channels/{channel_id}/check-now") @limiter.limit("10/minute") @handle_exceptions async def check_youtube_channel_now( channel_id: int, request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user) ): """Trigger an immediate check for a single YouTube channel.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) existing = monitor.get_channel(channel_id) if not existing: raise HTTPException(status_code=404, detail="Channel not found") global_settings = monitor.get_global_settings() if not global_settings.get('phrases'): raise ValidationError("No match phrases configured. Please add phrases in settings first.") async def run_check(): try: videos_found = await monitor.check_single_channel(channel_id) logger.info(f"YouTube channel {channel_id} check complete: {videos_found} videos found") except Exception as e: logger.error(f"Error checking YouTube channel {channel_id}: {e}") background_tasks.add_task(run_check) return { "success": True, "message": f"Check started for channel", "channel_name": existing.get('channel_name') or existing.get('channel_url') } @router.post("/platforms/youtube-monitors/check-selected") @limiter.limit("10/minute") @handle_exceptions async def check_selected_youtube_channels( request: Request, background_tasks: BackgroundTasks, channel_ids: list[int] = Body(..., embed=True), current_user: Dict = Depends(get_current_user) ): """Trigger an immediate check for selected YouTube channels.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) global_settings = monitor.get_global_settings() if not global_settings.get('phrases'): raise ValidationError("No match phrases configured. Please add phrases in settings first.") # Validate all channels exist for channel_id in channel_ids: existing = monitor.get_channel(channel_id) if not existing: raise HTTPException(status_code=404, detail=f"Channel {channel_id} not found") async def run_checks(): try: for channel_id in channel_ids: try: videos_found = await monitor.check_single_channel(channel_id) logger.info(f"YouTube channel {channel_id} check complete: {videos_found} videos found") except Exception as e: logger.error(f"Error checking YouTube channel {channel_id}: {e}") except Exception as e: logger.error(f"Error in batch channel check: {e}") background_tasks.add_task(run_checks) return { "success": True, "message": f"Check started for {len(channel_ids)} channel(s)", "channel_count": len(channel_ids) } @router.get("/platforms/youtube-monitors/channels/{channel_id}/history") @limiter.limit("100/minute") @handle_exceptions async def get_youtube_channel_history( channel_id: int, request: Request, limit: int = 50, current_user: Dict = Depends(get_current_user) ): """Get history for a YouTube channel.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) existing = monitor.get_channel(channel_id) if not existing: raise HTTPException(status_code=404, detail="Channel not found") history = monitor.get_channel_history(channel_id, limit) return { "success": True, "history": history, "count": len(history) } @router.get("/platforms/youtube-monitors/history") @limiter.limit("100/minute") @handle_exceptions async def get_all_youtube_history( request: Request, limit: int = 100, current_user: Dict = Depends(get_current_user) ): """Get combined history for all YouTube channels.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) history = monitor.get_all_history(limit) return { "success": True, "history": history, "count": len(history) } # ============================================================================ # YOUTUBE CHANNEL MONITORS - v11.20.0 Status Management # ============================================================================ @router.get("/platforms/youtube-monitors/channels") @limiter.limit("300/minute") @handle_exceptions async def get_youtube_channels_filtered( request: Request, status: Optional[str] = None, always_active: Optional[str] = None, search: Optional[str] = None, sort_field: str = 'name', sort_ascending: bool = True, limit: Optional[int] = None, offset: int = 0, current_user: Dict = Depends(get_current_user) ): """Get YouTube channel monitors with filtering, searching, sorting, and pagination.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) result = monitor.get_channels_filtered( status_filter=status, always_active_filter=always_active, search=search, sort_field=sort_field, sort_ascending=sort_ascending, limit=limit, offset=offset ) return { "success": True, "channels": result['channels'], "total": result['total'] } @router.get("/platforms/youtube-monitors/active") @limiter.limit("100/minute") @handle_exceptions async def get_active_youtube_channels( request: Request, current_user: Dict = Depends(get_current_user) ): """Get active YouTube channel monitors (deprecated - use /channels with status=active).""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) channels = monitor.get_active_channels() return { "success": True, "channels": channels, "count": len(channels) } @router.get("/platforms/youtube-monitors/paused") @limiter.limit("100/minute") @handle_exceptions async def get_paused_youtube_channels( request: Request, current_user: Dict = Depends(get_current_user) ): """Get paused YouTube channel monitors (deprecated - use /channels with status=paused_all).""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) channels = monitor.get_paused_channels() return { "success": True, "channels": channels, "count": len(channels) } @router.get("/platforms/youtube-monitors/statistics") @limiter.limit("300/minute") @handle_exceptions async def get_youtube_monitor_statistics( request: Request, current_user: Dict = Depends(get_current_user) ): """Get YouTube monitor statistics.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) stats = monitor.get_statistics() return { "success": True, "statistics": stats } @router.post("/platforms/youtube-monitors/channels/{channel_id}/pause") @limiter.limit("30/minute") @handle_exceptions async def pause_youtube_channel( request: Request, channel_id: int, reason: Optional[str] = None, current_user: Dict = Depends(get_current_user) ): """Manually pause a YouTube channel.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) existing = monitor.get_channel(channel_id) if not existing: raise HTTPException(status_code=404, detail="Channel not found") success = monitor.pause_channel(channel_id, reason=reason, auto=False) return { "success": success, "message": f"Channel '{existing['channel_name']}' paused" } @router.post("/platforms/youtube-monitors/channels/{channel_id}/resume") @limiter.limit("30/minute") @handle_exceptions async def resume_youtube_channel( request: Request, channel_id: int, current_user: Dict = Depends(get_current_user) ): """Resume a paused YouTube channel.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) existing = monitor.get_channel(channel_id) if not existing: raise HTTPException(status_code=404, detail="Channel not found") success = monitor.resume_channel(channel_id) return { "success": success, "message": f"Channel '{existing['channel_name']}' resumed" } @router.post("/platforms/youtube-monitors/channels/{channel_id}/toggle-always-active") @limiter.limit("30/minute") @handle_exceptions async def toggle_always_active( request: Request, channel_id: int, value: bool, current_user: Dict = Depends(get_current_user) ): """Toggle always_active flag for a channel.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) existing = monitor.get_channel(channel_id) if not existing: raise HTTPException(status_code=404, detail="Channel not found") success = monitor.toggle_always_active(channel_id, value) return { "success": success, "message": f"Always active {'enabled' if value else 'disabled'} for '{existing['channel_name']}'" } @router.post("/platforms/youtube-monitors/check-paused") @limiter.limit("5/minute") @handle_exceptions async def check_paused_channels_now( request: Request, current_user: Dict = Depends(get_current_user) ): """Check paused channels for new activity.""" from modules.youtube_channel_monitor import YouTubeChannelMonitor from ..core.config import Settings settings = Settings() monitor = YouTubeChannelMonitor(str(settings.DB_PATH)) resumed = monitor.check_paused_channels_sync() return { "success": True, "resumed_count": resumed, "message": f"{resumed} channels auto-resumed" if resumed > 0 else "No channels resumed" }