diff --git a/backend/app.py b/backend/app.py index 651148f..118ae68 100644 --- a/backend/app.py +++ b/backend/app.py @@ -482,8 +482,9 @@ async def restart_twitch_if_needed(settings: Dict[str, Any]): # Catch any other errors during cancellation (e.g., twitchio internal errors) logger.warning(f"Error while cancelling Twitch bot task: {e}") - # Give it a moment to fully clean up - await asyncio.sleep(0.1) + # Give TwitchIO's EventSub server more time to fully shut down + # This prevents CancelledError spam from aiohttp adapter callbacks + await asyncio.sleep(0.5) # Start new task if enabled if run_twitch_bot and settings.get("twitch", {}).get("enabled"): @@ -819,8 +820,8 @@ async def restart_youtube_if_needed(settings: Dict[str, Any]): except Exception as e: logger.warning(f"Error while cancelling YouTube bot task: {e}") - # Give it a moment to fully clean up - await asyncio.sleep(0.1) + # Give YouTube API more time to fully clean up + await asyncio.sleep(0.5) # Start new task if enabled if run_youtube_bot and settings.get("youtube", {}).get("enabled"): @@ -1512,9 +1513,37 @@ async def handle_moderation_event(evt: Dict[str, Any]): # ---------- Avatar Slot Management API ---------- # Avatar slot endpoints have been moved to routers/avatars.py +def custom_exception_handler(loop, context): + """ + Custom exception handler to suppress harmless TwitchIO internal errors during shutdown. + These CancelledError exceptions from aiohttp adapter callbacks are expected during bot restarts. + """ + exception = context.get("exception") + message = context.get("message", "") + handle = str(context.get("handle", "")) + + # Suppress TwitchIO EventSub server cancellation errors (harmless during shutdown) + if isinstance(exception, asyncio.CancelledError): + # Check if this is from TwitchIO's aiohttp adapter + if ("AiohttpAdapter._task_callback" in message or + "AiohttpAdapter._task_callback" in handle or + "aio_adapter.py" in message or + "web_runner.py" in message): + # This is an expected error during TwitchIO bot shutdown - don't log it + return + + # For all other exceptions, use the default handler + loop.default_exception_handler(context) + @app.on_event("startup") async def startup(): logger.info("FastAPI startup event triggered") + + # Install custom exception handler to suppress TwitchIO shutdown noise + loop = asyncio.get_running_loop() + loop.set_exception_handler(custom_exception_handler) + logger.info("Custom exception handler installed for cleaner TwitchIO shutdown") + try: # Broadcast initial avatar slot assignments to any connected clients await broadcast_avatar_slots() diff --git a/backend/modules/persistent_data.py b/backend/modules/persistent_data.py index fe2c74b..cfd6b93 100644 --- a/backend/modules/persistent_data.py +++ b/backend/modules/persistent_data.py @@ -86,18 +86,27 @@ def get_database_session(): yield session def get_settings() -> dict: - """Get application settings from database""" + """Get application settings from database, merged with defaults for any missing keys""" import json try: + # Load defaults + defaults = {} + if os.path.exists(DEFAULTS_PATH): + with open(DEFAULTS_PATH, 'r', encoding='utf-8') as f: + defaults = json.load(f) + with Session(engine) as session: row = session.exec(select(Setting).where(Setting.key == "settings")).first() if row: settings = json.loads(row.value_json) + # Merge defaults with loaded settings (settings take precedence) + # This ensures new settings are available even in old databases + merged = {**defaults, **settings} logger.info(f"Loaded settings from database: {DB_PATH}") - return settings + return merged else: logger.error("No settings found in database!") - return {} + return defaults except Exception as e: logger.error(f"Error loading settings: {e}") return {} diff --git a/backend/modules/youtube_listener.py b/backend/modules/youtube_listener.py index 2243fab..1ee99b1 100644 --- a/backend/modules/youtube_listener.py +++ b/backend/modules/youtube_listener.py @@ -9,6 +9,7 @@ from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from googleapiclient.errors import HttpError + from google.auth.exceptions import RefreshError YOUTUBE_AVAILABLE = True except ImportError as e: logger.error(f"Failed to import YouTube dependencies: {e}") @@ -16,6 +17,7 @@ YOUTUBE_AVAILABLE = False Credentials = None # type: ignore HttpError = Exception + RefreshError = Exception # type: ignore class YouTubeListener: @@ -82,15 +84,20 @@ async def find_active_stream(self) -> bool: else: logger.warning("No active live stream found for this channel") return False + except RefreshError as e: + logger.error("YouTube authentication token has expired or been revoked") + logger.info("Please reconnect your YouTube account in settings to continue using YouTube chat integration") + return False except HttpError as e: status_code = getattr(e, 'resp', {}).get('status', 0) if status_code == 401: - logger.error("YouTube API authentication error while finding active stream - token may be expired") + logger.error("YouTube API authentication error - token may be expired") + logger.info("Please reconnect your YouTube account in settings") else: logger.error(f"YouTube API error finding active stream: {e}") return False except Exception as e: - logger.error(f"Error finding active stream: {e}", exc_info=True) + logger.error(f"Error finding active stream: {e}") return False async def get_live_chat_id(self) -> Optional[str]: @@ -122,15 +129,20 @@ async def get_live_chat_id(self) -> Optional[str]: else: logger.error(f"Video {self.video_id} not found") return None + except RefreshError as e: + logger.error("YouTube authentication token has expired or been revoked") + logger.info("Please reconnect your YouTube account in settings to continue using YouTube chat integration") + return None except HttpError as e: status_code = getattr(e, 'resp', {}).get('status', 0) if status_code == 401: - logger.error("YouTube API authentication error while getting live chat ID - token may be expired") + logger.error("YouTube API authentication error - token may be expired") + logger.info("Please reconnect your YouTube account in settings") else: logger.error(f"YouTube API error getting live chat ID: {e}") return None except Exception as e: - logger.error(f"Error getting live chat ID: {e}", exc_info=True) + logger.error(f"Error getting live chat ID: {e}") return None async def listen_to_chat(self, on_event: Callable[[Dict[str, Any]], None]): diff --git a/backend/routers/avatars.py b/backend/routers/avatars.py index ae5cec3..f91c79c 100644 --- a/backend/routers/avatars.py +++ b/backend/routers/avatars.py @@ -152,7 +152,8 @@ async def api_upload_avatar(file: UploadFile, avatar_name: str = Form(...), avat # Broadcast refresh message to all connected clients and regenerate slot assignments # Import here to avoid circular imports - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) @@ -223,7 +224,8 @@ async def api_delete_avatar(avatar_id: int): delete_avatar(avatar_id) # Broadcast refresh message and regenerate slot assignments - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) @@ -255,7 +257,8 @@ async def api_delete_avatar_group(group_id: str): """Delete an entire avatar group (all avatars with the same group_id)""" try: result = delete_avatar_group(group_id) - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) get_active_avatar_slots().clear() @@ -283,7 +286,8 @@ async def api_update_avatar_position(group_id: str, position_data: dict): spawn_position = position_data.get("spawn_position") result = update_avatar_group_position(group_id, spawn_position) - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) get_active_avatar_slots().clear() @@ -309,7 +313,8 @@ async def api_toggle_avatar_group_disabled(group_id: str): """Toggle the disabled status of an entire avatar group""" try: result = toggle_avatar_group_disabled(group_id) - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) get_active_avatar_slots().clear() @@ -335,7 +340,8 @@ async def api_toggle_avatar_group_disabled(group_id: str): async def api_regenerate_avatar_slots(): """Force regeneration of avatar slot assignments (re-randomize avatars)""" try: - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) @@ -392,7 +398,7 @@ async def api_release_avatar_slot(slot_id: str): async def api_get_avatar_queue(): """Get current avatar message queue status""" try: - from app import avatar_message_queue + from modules.queue_manager import avatar_message_queue from modules.avatars import get_active_avatar_slots, get_avatar_slot_assignments active_avatar_slots = get_active_avatar_slots() @@ -447,7 +453,8 @@ async def api_create_avatar_slot(slot_data: dict): ) # Broadcast update to all clients - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) @@ -492,7 +499,8 @@ async def api_update_configured_slot(slot_id: int, slot_data: dict): return {"success": False, "error": "Slot not found"} # Broadcast update to all clients - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) @@ -523,7 +531,8 @@ async def api_delete_configured_slot(slot_id: int): return {"success": False, "error": "Slot not found"} # Broadcast update to all clients - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) @@ -551,7 +560,8 @@ async def api_delete_all_configured_slots(): count = delete_all_avatar_slots() # Broadcast update to all clients - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import (generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id) @@ -569,4 +579,4 @@ async def api_delete_all_configured_slots(): return {"success": True, "deleted_count": count} except Exception as e: logger.error(f"Failed to delete all avatar slots: {e}") - return {"success": False, "error": str(e)} \ No newline at end of file + return {"success": False, "error": str(e)} diff --git a/backend/routers/config_backup.py b/backend/routers/config_backup.py index 5555b07..0de0ba7 100644 --- a/backend/routers/config_backup.py +++ b/backend/routers/config_backup.py @@ -266,7 +266,8 @@ async def import_config( logger.info(f"Imported {stats['avatars_imported']} avatars, copied {stats['images_copied']} images") # Regenerate avatar slot assignments - from app import hub, avatar_message_queue + from app import hub + from modules.queue_manager import avatar_message_queue from modules.avatars import ( generate_avatar_slot_assignments, get_active_avatar_slots, get_avatar_slot_assignments, get_avatar_assignments_generation_id diff --git a/backend/routers/system.py b/backend/routers/system.py index 6e5d27f..add9df5 100644 --- a/backend/routers/system.py +++ b/backend/routers/system.py @@ -501,7 +501,8 @@ async def test_parallel_limit(): duration = time.time() - start_time # Import queue info - from app import active_tts_jobs, parallel_message_queue, total_active_tts_count + from app import active_tts_jobs, total_active_tts_count + from modules.queue_manager import parallel_message_queue result = { "success": True, diff --git a/backend/tests/test_clearchat.py b/backend/tests/test_clearchat.py index 85e2914..9e77d87 100644 --- a/backend/tests/test_clearchat.py +++ b/backend/tests/test_clearchat.py @@ -4,10 +4,24 @@ """ import pytest import asyncio +import os from unittest.mock import MagicMock, AsyncMock, patch from modules.twitch_listener import TwitchBot +def check_twitch_credentials(): + """Check if Twitch credentials are available for testing""" + import modules.persistent_data + return bool(modules.persistent_data.TWITCH_CLIENT_SECRET) + + +# Skip tests requiring TwitchBot if credentials are not available +skip_if_no_credentials = pytest.mark.skipif( + not check_twitch_credentials(), + reason="Twitch credentials not available (TWITCH_CLIENT_SECRET missing)" +) + + class TestClearChatEvents: """Test CLEARCHAT event parsing and handling""" @@ -41,6 +55,7 @@ def test_normalize_tags_list(self): assert result["ban-duration"] == "600" @pytest.mark.asyncio + @skip_if_no_credentials async def test_clearchat_ban_event(self): """Test ban event (no duration)""" events_received = [] @@ -77,6 +92,7 @@ def on_event(event): assert "tags" in event @pytest.mark.asyncio + @skip_if_no_credentials async def test_clearchat_timeout_event(self): """Test timeout event (with duration)""" events_received = [] @@ -112,6 +128,7 @@ def on_event(event): assert "tags" in event @pytest.mark.asyncio + @skip_if_no_credentials async def test_clearchat_chat_clear(self): """Test general chat clear (no target user)""" events_received = [] @@ -140,6 +157,7 @@ def on_event(event): assert "target_user" not in event or event.get("target_user") is None @pytest.mark.asyncio + @skip_if_no_credentials async def test_clearchat_with_display_name_fallback(self): """Test CLEARCHAT using display-name when login not available""" events_received = [] diff --git a/backend/version.py b/backend/version.py index 45b1079..31f37c3 100644 --- a/backend/version.py +++ b/backend/version.py @@ -3,4 +3,4 @@ This file is automatically updated during CI/CD builds """ -__version__ = "1.3.0" +__version__ = "1.3.1" diff --git a/frontend/src/components/settings/GeneralSettings.jsx b/frontend/src/components/settings/GeneralSettings.jsx index ba9da40..74b5830 100644 --- a/frontend/src/components/settings/GeneralSettings.jsx +++ b/frontend/src/components/settings/GeneralSettings.jsx @@ -32,7 +32,7 @@ function GeneralSettings({ settings, setSettings, updateSettings, apiUrl }) { const currentLimit = settings.parallelMessageLimit const presetValues = [1, 2, 3, 4, 5, 6, 8, 10, 15, 20, null] - if (currentLimit !== null && !presetValues.includes(currentLimit)) { + if (currentLimit !== null && currentLimit !== undefined && !presetValues.includes(currentLimit)) { setIsCustomMode(true) setCustomLimit(currentLimit.toString()) } else {