""" Easynews Router Handles Easynews integration: - Configuration management (credentials, proxy settings) - Search term management - Manual check triggers - Results browsing and downloads """ import asyncio from concurrent.futures import ThreadPoolExecutor from datetime import datetime from typing import Dict, List, Optional from fastapi import APIRouter, BackgroundTasks, 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, require_admin, get_app_state from ..core.exceptions import handle_exceptions from modules.universal_logger import get_logger logger = get_logger('API') router = APIRouter(prefix="/api/easynews", tags=["Easynews"]) limiter = Limiter(key_func=get_remote_address) # Thread pool for blocking operations _executor = ThreadPoolExecutor(max_workers=2) # ============================================================================ # PYDANTIC MODELS # ============================================================================ class EasynewsConfigUpdate(BaseModel): username: Optional[str] = None password: Optional[str] = None enabled: Optional[bool] = None check_interval_hours: Optional[int] = None auto_download: Optional[bool] = None min_quality: Optional[str] = None proxy_enabled: Optional[bool] = None proxy_type: Optional[str] = None proxy_host: Optional[str] = None proxy_port: Optional[int] = None proxy_username: Optional[str] = None proxy_password: Optional[str] = None notifications_enabled: Optional[bool] = None # ============================================================================ # HELPER FUNCTIONS # ============================================================================ def _get_monitor(): """Get the Easynews monitor instance.""" from modules.easynews_monitor import EasynewsMonitor app_state = get_app_state() db_path = str(app_state.db.db_path) # Convert Path to string return EasynewsMonitor(db_path) # ============================================================================ # CONFIGURATION ENDPOINTS # ============================================================================ @router.get("/config") @limiter.limit("30/minute") @handle_exceptions async def get_config( request: Request, current_user: Dict = Depends(get_current_user) ): """Get Easynews configuration (passwords masked).""" monitor = _get_monitor() config = monitor.get_config() # Mask password for security if config.get('password'): config['password'] = '********' if config.get('proxy_password'): config['proxy_password'] = '********' return { "success": True, "config": config } @router.put("/config") @limiter.limit("10/minute") @handle_exceptions async def update_config( request: Request, config: EasynewsConfigUpdate, current_user: Dict = Depends(require_admin) ): """Update Easynews configuration.""" monitor = _get_monitor() # Build update kwargs kwargs = {} if config.username is not None: kwargs['username'] = config.username if config.password is not None and config.password != '********': kwargs['password'] = config.password if config.enabled is not None: kwargs['enabled'] = config.enabled if config.check_interval_hours is not None: kwargs['check_interval_hours'] = config.check_interval_hours if config.auto_download is not None: kwargs['auto_download'] = config.auto_download if config.min_quality is not None: kwargs['min_quality'] = config.min_quality if config.proxy_enabled is not None: kwargs['proxy_enabled'] = config.proxy_enabled if config.proxy_type is not None: kwargs['proxy_type'] = config.proxy_type if config.proxy_host is not None: kwargs['proxy_host'] = config.proxy_host if config.proxy_port is not None: kwargs['proxy_port'] = config.proxy_port if config.proxy_username is not None: kwargs['proxy_username'] = config.proxy_username if config.proxy_password is not None and config.proxy_password != '********': kwargs['proxy_password'] = config.proxy_password if config.notifications_enabled is not None: kwargs['notifications_enabled'] = config.notifications_enabled if not kwargs: return {"success": False, "message": "No updates provided"} success = monitor.update_config(**kwargs) return { "success": success, "message": "Configuration updated" if success else "Update failed" } @router.post("/test") @limiter.limit("5/minute") @handle_exceptions async def test_connection( request: Request, current_user: Dict = Depends(get_current_user) ): """Test Easynews connection with current credentials.""" monitor = _get_monitor() loop = asyncio.get_event_loop() result = await loop.run_in_executor(_executor, monitor.test_connection) return result # ============================================================================ # CELEBRITY ENDPOINTS (uses tracked celebrities from Appearances) # ============================================================================ @router.get("/celebrities") @limiter.limit("30/minute") @handle_exceptions async def get_celebrities( request: Request, current_user: Dict = Depends(get_current_user) ): """Get all tracked celebrities that will be searched on Easynews.""" monitor = _get_monitor() celebrities = monitor.get_celebrities() return { "success": True, "celebrities": celebrities, "count": len(celebrities) } # ============================================================================ # RESULTS ENDPOINTS # ============================================================================ @router.get("/results") @limiter.limit("30/minute") @handle_exceptions async def get_results( request: Request, status: Optional[str] = None, celebrity_id: Optional[int] = None, limit: int = 100, offset: int = 0, current_user: Dict = Depends(get_current_user) ): """Get discovered results with optional filters.""" monitor = _get_monitor() results = monitor.get_results( status=status, celebrity_id=celebrity_id, limit=limit, offset=offset, ) # Get total count total = monitor.get_result_count(status=status) return { "success": True, "results": results, "count": len(results), "total": total } @router.post("/results/{result_id}/status") @limiter.limit("30/minute") @handle_exceptions async def update_result_status( request: Request, result_id: int, status: str, current_user: Dict = Depends(get_current_user) ): """Update a result's status (e.g., mark as ignored).""" valid_statuses = ['new', 'downloaded', 'ignored', 'failed'] if status not in valid_statuses: raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") monitor = _get_monitor() success = monitor.update_result_status(result_id, status) return { "success": success, "message": f"Status updated to {status}" if success else "Update failed" } @router.post("/results/{result_id}/download") @limiter.limit("10/minute") @handle_exceptions async def download_result( request: Request, result_id: int, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user) ): """Start downloading a result.""" monitor = _get_monitor() def do_download(): return monitor.download_result(result_id) loop = asyncio.get_event_loop() result = await loop.run_in_executor(_executor, do_download) return result # ============================================================================ # CHECK ENDPOINTS # ============================================================================ @router.get("/status") @limiter.limit("60/minute") @handle_exceptions async def get_status( request: Request, current_user: Dict = Depends(get_current_user) ): """Get current check status.""" monitor = _get_monitor() status = monitor.get_status() config = monitor.get_config() celebrity_count = monitor.get_celebrity_count() return { "success": True, "status": status, "last_check": config.get('last_check'), "enabled": config.get('enabled', False), "has_credentials": config.get('has_credentials', False), "celebrity_count": celebrity_count, } @router.post("/check") @limiter.limit("5/minute") @handle_exceptions async def trigger_check( request: Request, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user) ): """Trigger a manual check for all tracked celebrities.""" monitor = _get_monitor() status = monitor.get_status() if status.get('is_running'): return { "success": False, "message": "Check already in progress" } def do_check(): return monitor.check_all_celebrities() loop = asyncio.get_event_loop() background_tasks.add_task(loop.run_in_executor, _executor, do_check) return { "success": True, "message": "Check started" } @router.post("/check/{search_id}") @limiter.limit("5/minute") @handle_exceptions async def trigger_single_check( request: Request, search_id: int, background_tasks: BackgroundTasks, current_user: Dict = Depends(get_current_user) ): """Trigger a manual check for a specific search term.""" monitor = _get_monitor() status = monitor.get_status() if status.get('is_running'): return { "success": False, "message": "Check already in progress" } # Verify search exists search = monitor.get_search(search_id) if not search: raise HTTPException(status_code=404, detail="Search not found") def do_check(): return monitor.check_single_search(search_id) loop = asyncio.get_event_loop() background_tasks.add_task(loop.run_in_executor, _executor, do_check) return { "success": True, "message": f"Check started for: {search['search_term']}" } # ============================================================================ # SEARCH MANAGEMENT ENDPOINTS # ============================================================================ class EasynewsSearchCreate(BaseModel): search_term: str media_type: Optional[str] = 'any' tmdb_id: Optional[int] = None tmdb_title: Optional[str] = None poster_url: Optional[str] = None class EasynewsSearchUpdate(BaseModel): search_term: Optional[str] = None media_type: Optional[str] = None enabled: Optional[bool] = None tmdb_id: Optional[int] = None tmdb_title: Optional[str] = None poster_url: Optional[str] = None @router.get("/searches") @limiter.limit("30/minute") @handle_exceptions async def get_searches( request: Request, current_user: Dict = Depends(get_current_user) ): """Get all saved search terms.""" monitor = _get_monitor() searches = monitor.get_all_searches() return { "success": True, "searches": searches, "count": len(searches) } @router.post("/searches") @limiter.limit("10/minute") @handle_exceptions async def add_search( request: Request, search: EasynewsSearchCreate, current_user: Dict = Depends(get_current_user) ): """Add a new search term.""" monitor = _get_monitor() search_id = monitor.add_search( search_term=search.search_term, media_type=search.media_type, tmdb_id=search.tmdb_id, tmdb_title=search.tmdb_title, poster_url=search.poster_url ) if search_id: return { "success": True, "id": search_id, "message": f"Search term '{search.search_term}' added" } else: return { "success": False, "message": "Failed to add search term" } @router.put("/searches/{search_id}") @limiter.limit("10/minute") @handle_exceptions async def update_search( request: Request, search_id: int, updates: EasynewsSearchUpdate, current_user: Dict = Depends(get_current_user) ): """Update an existing search term.""" monitor = _get_monitor() # Build update kwargs kwargs = {} if updates.search_term is not None: kwargs['search_term'] = updates.search_term if updates.media_type is not None: kwargs['media_type'] = updates.media_type if updates.enabled is not None: kwargs['enabled'] = updates.enabled if updates.tmdb_id is not None: kwargs['tmdb_id'] = updates.tmdb_id if updates.tmdb_title is not None: kwargs['tmdb_title'] = updates.tmdb_title if updates.poster_url is not None: kwargs['poster_url'] = updates.poster_url if not kwargs: return {"success": False, "message": "No updates provided"} success = monitor.update_search(search_id, **kwargs) return { "success": success, "message": "Search updated" if success else "Update failed" } @router.delete("/searches/{search_id}") @limiter.limit("10/minute") @handle_exceptions async def delete_search( request: Request, search_id: int, current_user: Dict = Depends(get_current_user) ): """Delete a search term.""" monitor = _get_monitor() success = monitor.delete_search(search_id) return { "success": success, "message": "Search deleted" if success else "Delete failed" }