""" Config and Logs Router Handles configuration and logging operations: - Get/update application configuration - Log viewing (single component, merged) - Notification history and stats - Changelog retrieval """ import json import re from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Optional from fastapi import APIRouter, Body, Depends, HTTPException, Query, 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.config import settings from ..core.exceptions import ( handle_exceptions, ValidationError, RecordNotFoundError ) from ..core.responses import now_iso8601 from modules.universal_logger import get_logger logger = get_logger('API') router = APIRouter(prefix="/api", tags=["Configuration"]) limiter = Limiter(key_func=get_remote_address) LOG_PATH = settings.PROJECT_ROOT / 'logs' # ============================================================================ # PYDANTIC MODELS # ============================================================================ class ConfigUpdate(BaseModel): config: Dict class MergedLogsRequest(BaseModel): lines: int = 500 components: List[str] around_time: Optional[str] = None # ISO timestamp to center logs around # ============================================================================ # CONFIGURATION ENDPOINTS # ============================================================================ @router.get("/config") @limiter.limit("100/minute") @handle_exceptions async def get_config( request: Request, current_user: Dict = Depends(get_current_user) ): """Get current configuration.""" app_state = get_app_state() return app_state.settings.get_all() @router.put("/config") @limiter.limit("20/minute") @handle_exceptions async def update_config( request: Request, current_user: Dict = Depends(require_admin), update: ConfigUpdate = Body(...) ): """ Update configuration (admin only). Saves configuration to database and updates in-memory state. """ app_state = get_app_state() if not isinstance(update.config, dict): raise ValidationError("Invalid configuration format") logger.debug(f"Incoming config keys: {list(update.config.keys())}", module="Config") # Save to database for key, value in update.config.items(): app_state.settings.set(key, value, category=key, updated_by='api') # Refresh in-memory config so other endpoints see updated values app_state.config = app_state.settings.get_all() # Broadcast update try: if hasattr(app_state, 'websocket_manager') and app_state.websocket_manager: await app_state.websocket_manager.broadcast({ "type": "config_updated", "timestamp": now_iso8601() }) except Exception as e: logger.debug(f"Failed to broadcast config update: {e}", module="Config") return {"success": True, "message": "Configuration updated"} # ============================================================================ # LOG ENDPOINTS # ============================================================================ @router.get("/logs") @limiter.limit("100/minute") @handle_exceptions async def get_logs( request: Request, current_user: Dict = Depends(get_current_user), lines: int = 100, component: Optional[str] = None ): """Get recent log entries from the most recent log files.""" if not LOG_PATH.exists(): return {"logs": [], "available_components": []} all_log_files = [] # Find date-stamped logs: YYYYMMDD_component.log or YYYYMMDD_HHMMSS_component.log seen_paths = set() for log_file in LOG_PATH.glob('*.log'): if '_' not in log_file.stem: continue parts = log_file.stem.split('_') if not parts[0].isdigit(): continue try: stat_info = log_file.stat() if stat_info.st_size == 0: continue mtime = stat_info.st_mtime # YYYYMMDD_HHMMSS_component.log (3+ parts, first two numeric) if len(parts) >= 3 and parts[1].isdigit(): comp_name = '_'.join(parts[2:]) # YYYYMMDD_component.log (2+ parts, first numeric) elif len(parts) >= 2: comp_name = '_'.join(parts[1:]) else: continue seen_paths.add(log_file) all_log_files.append({ 'path': log_file, 'mtime': mtime, 'component': comp_name }) except OSError: pass # Also check for old-style logs (no date prefix) for log_file in LOG_PATH.glob('*.log'): if log_file in seen_paths: continue if '_' in log_file.stem and log_file.stem.split('_')[0].isdigit(): continue try: stat_info = log_file.stat() if stat_info.st_size == 0: continue mtime = stat_info.st_mtime all_log_files.append({ 'path': log_file, 'mtime': mtime, 'component': log_file.stem }) except OSError: pass if not all_log_files: return {"logs": [], "available_components": []} components = sorted(set(f['component'] for f in all_log_files)) if component: log_files = [f for f in all_log_files if f['component'] == component] else: log_files = all_log_files if not log_files: return {"logs": [], "available_components": components} most_recent = max(log_files, key=lambda x: x['mtime']) try: with open(most_recent['path'], 'r', encoding='utf-8', errors='ignore') as f: all_lines = f.readlines() recent_lines = all_lines[-lines:] return { "logs": [line.strip() for line in recent_lines], "available_components": components, "current_component": most_recent['component'], "log_file": str(most_recent['path'].name) } except Exception as e: logger.error(f"Error reading log file: {e}", module="Logs") return {"logs": [], "available_components": components, "error": str(e)} @router.post("/logs/merged") @limiter.limit("100/minute") @handle_exceptions async def get_merged_logs( request: Request, body: MergedLogsRequest, current_user: Dict = Depends(get_current_user) ): """Get merged log entries from multiple components, sorted by timestamp.""" lines = body.lines components = body.components if not LOG_PATH.exists(): return {"logs": [], "available_components": [], "selected_components": []} all_log_files = [] # Find date-stamped logs for log_file in LOG_PATH.glob('*_*.log'): try: stat_info = log_file.stat() if stat_info.st_size == 0: continue mtime = stat_info.st_mtime parts = log_file.stem.split('_') # Check OLD format FIRST (YYYYMMDD_HHMMSS_component.log) if len(parts) >= 3 and parts[0].isdigit() and len(parts[0]) == 8 and parts[1].isdigit() and len(parts[1]) == 6: comp_name = '_'.join(parts[2:]) all_log_files.append({ 'path': log_file, 'mtime': mtime, 'component': comp_name }) # Then check NEW format (YYYYMMDD_component.log) elif len(parts) >= 2 and parts[0].isdigit() and len(parts[0]) == 8: comp_name = '_'.join(parts[1:]) all_log_files.append({ 'path': log_file, 'mtime': mtime, 'component': comp_name }) except OSError: pass # Also check for old-style logs for log_file in LOG_PATH.glob('*.log'): if '_' in log_file.stem and log_file.stem.split('_')[0].isdigit(): continue try: stat_info = log_file.stat() if stat_info.st_size == 0: continue mtime = stat_info.st_mtime all_log_files.append({ 'path': log_file, 'mtime': mtime, 'component': log_file.stem }) except OSError: pass if not all_log_files: return {"logs": [], "available_components": [], "selected_components": []} available_components = sorted(set(f['component'] for f in all_log_files)) if not components or len(components) == 0: return { "logs": [], "available_components": available_components, "selected_components": [] } selected_log_files = [f for f in all_log_files if f['component'] in components] if not selected_log_files: return { "logs": [], "available_components": available_components, "selected_components": components } all_logs_with_timestamps = [] for comp in components: comp_files = [f for f in selected_log_files if f['component'] == comp] if not comp_files: continue most_recent = max(comp_files, key=lambda x: x['mtime']) try: with open(most_recent['path'], 'r', encoding='utf-8', errors='ignore') as f: all_lines = f.readlines() recent_lines = all_lines[-lines:] for line in recent_lines: line = line.strip() if not line: continue # Match timestamp with optional microseconds timestamp_match = re.match(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?:\.(\d+))?', line) if timestamp_match: timestamp_str = timestamp_match.group(1) microseconds = timestamp_match.group(2) try: timestamp = datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S') # Add microseconds if present if microseconds: # Pad or truncate to 6 digits for microseconds microseconds = microseconds[:6].ljust(6, '0') timestamp = timestamp.replace(microsecond=int(microseconds)) all_logs_with_timestamps.append({ 'timestamp': timestamp, 'log': line }) except ValueError: all_logs_with_timestamps.append({ 'timestamp': None, 'log': line }) else: all_logs_with_timestamps.append({ 'timestamp': None, 'log': line }) except Exception as e: logger.error(f"Error reading log file {most_recent['path']}: {e}", module="Logs") continue # Sort by timestamp sorted_logs = sorted( all_logs_with_timestamps, key=lambda x: x['timestamp'] if x['timestamp'] is not None else datetime.min ) # If around_time is specified, center the logs around that timestamp if body.around_time: try: # Parse the target timestamp target_time = datetime.fromisoformat(body.around_time.replace('Z', '+00:00').replace('+00:00', '')) # Find logs within 10 minutes of the target time time_window = timedelta(minutes=10) filtered_logs = [ entry for entry in sorted_logs if entry['timestamp'] is not None and abs((entry['timestamp'] - target_time).total_seconds()) <= time_window.total_seconds() ] # If we found logs near the target time, use those # Otherwise fall back to all logs and try to find the closest ones if filtered_logs: merged_logs = [entry['log'] for entry in filtered_logs] else: # Find the closest logs to the target time logs_with_diff = [ (entry, abs((entry['timestamp'] - target_time).total_seconds()) if entry['timestamp'] else float('inf')) for entry in sorted_logs ] logs_with_diff.sort(key=lambda x: x[1]) # Take the closest logs, centered around the target closest_logs = logs_with_diff[:lines] closest_logs.sort(key=lambda x: x[0]['timestamp'] if x[0]['timestamp'] else datetime.min) merged_logs = [entry[0]['log'] for entry in closest_logs] except (ValueError, TypeError): # If parsing fails, fall back to normal behavior merged_logs = [entry['log'] for entry in sorted_logs] if len(merged_logs) > lines: merged_logs = merged_logs[-lines:] else: merged_logs = [entry['log'] for entry in sorted_logs] if len(merged_logs) > lines: merged_logs = merged_logs[-lines:] return { "logs": merged_logs, "available_components": available_components, "selected_components": components, "total_logs": len(merged_logs) } # ============================================================================ # NOTIFICATION ENDPOINTS # ============================================================================ @router.get("/notifications") @limiter.limit("500/minute") @handle_exceptions async def get_notifications( request: Request, current_user: Dict = Depends(get_current_user), limit: int = 50, offset: int = 0, platform: Optional[str] = None, source: Optional[str] = None ): """Get notification history with pagination and filters.""" app_state = get_app_state() with app_state.db.get_connection() as conn: cursor = conn.cursor() query = """ SELECT id, platform, source, content_type, message, title, priority, download_count, sent_at, status, metadata FROM notifications WHERE 1=1 """ params = [] if platform: query += " AND platform = ?" params.append(platform) if source: # Handle standardized source names if source == 'YouTube Monitor': query += " AND source = ?" params.append('youtube_monitor') else: query += " AND source = ?" params.append(source) # Get total count count_query = query.replace( "SELECT id, platform, source, content_type, message, title, priority, download_count, sent_at, status, metadata", "SELECT COUNT(*)" ) cursor.execute(count_query, params) result = cursor.fetchone() total = result[0] if result else 0 # Add ordering and pagination query += " ORDER BY sent_at DESC LIMIT ? OFFSET ?" params.extend([limit, offset]) cursor.execute(query, params) rows = cursor.fetchall() notifications = [] for row in rows: notifications.append({ 'id': row[0], 'platform': row[1], 'source': row[2], 'content_type': row[3], 'message': row[4], 'title': row[5], 'priority': row[6], 'download_count': row[7], 'sent_at': row[8], 'status': row[9], 'metadata': json.loads(row[10]) if row[10] else None }) return { 'notifications': notifications, 'total': total, 'limit': limit, 'offset': offset } @router.get("/notifications/stats") @limiter.limit("500/minute") @handle_exceptions async def get_notification_stats( request: Request, current_user: Dict = Depends(get_current_user) ): """Get notification statistics.""" app_state = get_app_state() with app_state.db.get_connection() as conn: cursor = conn.cursor() # Total sent cursor.execute("SELECT COUNT(*) FROM notifications WHERE status = 'sent'") result = cursor.fetchone() total_sent = result[0] if result else 0 # Total failed cursor.execute("SELECT COUNT(*) FROM notifications WHERE status = 'failed'") result = cursor.fetchone() total_failed = result[0] if result else 0 # By platform (consolidate and filter) cursor.execute(""" SELECT platform, COUNT(*) as count FROM notifications GROUP BY platform ORDER BY count DESC """) raw_platforms = {row[0]: row[1] for row in cursor.fetchall()} # Consolidate similar platforms and exclude system by_platform = {} for platform, count in raw_platforms.items(): # Skip system notifications if platform == 'system': continue # Consolidate forum -> forums if platform == 'forum': by_platform['forums'] = by_platform.get('forums', 0) + count # Consolidate fastdl -> instagram (fastdl is an Instagram download method) elif platform == 'fastdl': by_platform['instagram'] = by_platform.get('instagram', 0) + count # Standardize youtube_monitor/youtube_monitors -> youtube elif platform in ('youtube_monitor', 'youtube_monitors'): by_platform['youtube'] = by_platform.get('youtube', 0) + count else: by_platform[platform] = by_platform.get(platform, 0) + count # Recent 24h cursor.execute(""" SELECT COUNT(*) FROM notifications WHERE sent_at >= datetime('now', '-1 day') """) result = cursor.fetchone() recent_24h = result[0] if result else 0 # Unique sources for filter dropdown cursor.execute(""" SELECT DISTINCT source FROM notifications WHERE source IS NOT NULL AND source != '' ORDER BY source """) raw_sources = [row[0] for row in cursor.fetchall()] # Standardize source names and track special sources sources = [] has_youtube_monitor = False has_log_errors = False for source in raw_sources: # Standardize youtube_monitor -> YouTube Monitor if source == 'youtube_monitor': has_youtube_monitor = True elif source == 'Log Errors': has_log_errors = True else: sources.append(source) # Put special sources at the top priority_sources = [] if has_youtube_monitor: priority_sources.append('YouTube Monitor') if has_log_errors: priority_sources.append('Log Errors') sources = priority_sources + sources return { 'total_sent': total_sent, 'total_failed': total_failed, 'by_platform': by_platform, 'recent_24h': recent_24h, 'sources': sources } @router.delete("/notifications/{notification_id}") @limiter.limit("100/minute") @handle_exceptions async def delete_notification( request: Request, notification_id: int, current_user: Dict = Depends(get_current_user) ): """Delete a single notification from history.""" app_state = get_app_state() with app_state.db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Check if notification exists cursor.execute("SELECT id FROM notifications WHERE id = ?", (notification_id,)) if not cursor.fetchone(): raise RecordNotFoundError( "Notification not found", {"notification_id": notification_id} ) # Delete the notification cursor.execute("DELETE FROM notifications WHERE id = ?", (notification_id,)) conn.commit() return { 'success': True, 'message': 'Notification deleted', 'notification_id': notification_id } # ============================================================================ # CHANGELOG ENDPOINT # ============================================================================ @router.get("/changelog") @limiter.limit("100/minute") @handle_exceptions async def get_changelog( request: Request, current_user: Dict = Depends(get_current_user) ): """Get changelog data from JSON file.""" changelog_path = settings.PROJECT_ROOT / "data" / "changelog.json" if not changelog_path.exists(): return {"versions": []} with open(changelog_path, 'r') as f: changelog_data = json.load(f) return {"versions": changelog_data} # ============================================================================ # APPEARANCE CONFIG ENDPOINTS # ============================================================================ class AppearanceConfigUpdate(BaseModel): tmdb_api_key: Optional[str] = None tmdb_enabled: bool = True tmdb_check_interval_hours: int = 12 notify_new_appearances: bool = True notify_days_before: int = 1 podcast_enabled: bool = False radio_enabled: bool = False podchaser_client_id: Optional[str] = None podchaser_client_secret: Optional[str] = None podchaser_api_key: Optional[str] = None podchaser_enabled: bool = False imdb_enabled: bool = True @router.get("/config/appearance") @limiter.limit("100/minute") @handle_exceptions async def get_appearance_config( request: Request, current_user: Dict = Depends(get_current_user) ): """Get appearance tracking configuration.""" db = get_app_state().db try: with db.get_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT tmdb_api_key, tmdb_enabled, tmdb_check_interval_hours, tmdb_last_check, notify_new_appearances, notify_days_before, podcast_enabled, radio_enabled, podchaser_client_id, podchaser_client_secret, podchaser_api_key, podchaser_enabled, podchaser_last_check, imdb_enabled FROM appearance_config WHERE id = 1 ''') row = cursor.fetchone() if not row: # Initialize config if not exists cursor.execute('INSERT OR IGNORE INTO appearance_config (id) VALUES (1)') conn.commit() return { "tmdb_api_key": None, "tmdb_enabled": True, "tmdb_check_interval_hours": 12, "tmdb_last_check": None, "notify_new_appearances": True, "notify_days_before": 1, "podcast_enabled": False, "radio_enabled": False, "podchaser_client_id": None, "podchaser_client_secret": None, "podchaser_api_key": None, "podchaser_enabled": False, "podchaser_last_check": None } return { "tmdb_api_key": row[0], "tmdb_enabled": bool(row[1]), "tmdb_check_interval_hours": row[2], "tmdb_last_check": row[3], "notify_new_appearances": bool(row[4]), "notify_days_before": row[5], "podcast_enabled": bool(row[6]), "radio_enabled": bool(row[7]), "podchaser_client_id": row[8] if len(row) > 8 else None, "podchaser_client_secret": row[9] if len(row) > 9 else None, "podchaser_api_key": row[10] if len(row) > 10 else None, "podchaser_enabled": bool(row[11]) if len(row) > 11 else False, "podchaser_last_check": row[12] if len(row) > 12 else None, "imdb_enabled": bool(row[13]) if len(row) > 13 else True } except Exception as e: logger.error(f"Error getting appearance config: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/config/appearance") @limiter.limit("100/minute") @handle_exceptions async def update_appearance_config( request: Request, config: AppearanceConfigUpdate, current_user: Dict = Depends(get_current_user) ): """Update appearance tracking configuration.""" db = get_app_state().db try: with db.get_connection(for_write=True) as conn: cursor = conn.cursor() # Update config cursor.execute(''' UPDATE appearance_config SET tmdb_api_key = ?, tmdb_enabled = ?, tmdb_check_interval_hours = ?, notify_new_appearances = ?, notify_days_before = ?, podcast_enabled = ?, radio_enabled = ?, podchaser_client_id = ?, podchaser_client_secret = ?, podchaser_api_key = ?, podchaser_enabled = ?, imdb_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1 ''', (config.tmdb_api_key, config.tmdb_enabled, config.tmdb_check_interval_hours, config.notify_new_appearances, config.notify_days_before, config.podcast_enabled, config.radio_enabled, config.podchaser_client_id, config.podchaser_client_secret, config.podchaser_api_key, config.podchaser_enabled, config.imdb_enabled)) conn.commit() return { "success": True, "message": "Appearance configuration updated successfully" } except Exception as e: logger.error(f"Error updating appearance config: {e}") raise HTTPException(status_code=500, detail=str(e))