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

479 lines
14 KiB
Python

"""
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"
}