Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
56e44f8
pop up mode and chat buble toggle
pladisdev Nov 2, 2025
82db2b0
potential audio fix
pladisdev Nov 2, 2025
579683d
linux sh and better docker support
pladisdev Nov 2, 2025
fad6d1a
fixed linux build?
pladisdev Nov 2, 2025
a12de82
linux fix part 2
pladisdev Nov 2, 2025
5d79afe
docker fix part 1
pladisdev Nov 2, 2025
0f3af89
Initial plan
Copilot Nov 2, 2025
d5d90f6
Initial plan
Copilot Nov 2, 2025
cbf5fcd
Initial plan
Copilot Nov 2, 2025
5a5d1d5
Initial plan
Copilot Nov 2, 2025
207ba9b
Initial plan
Copilot Nov 2, 2025
76b9912
Initial plan
Copilot Nov 2, 2025
366b640
Initial plan
Copilot Nov 2, 2025
ebc7bb7
Update frontend/src/pages/YappersPage.jsx
pladisdev Nov 2, 2025
be9a086
Initial plan
Copilot Nov 2, 2025
3fc9146
Update frontend/src/pages/YappersPage.jsx
pladisdev Nov 2, 2025
914b789
Replace sys.platform with platform.system() for OS detection
Copilot Nov 2, 2025
938886f
Fix race condition in popup avatar lifecycle
Copilot Nov 2, 2025
7737a2f
Extract magic numbers to named constants for better readability
Copilot Nov 2, 2025
3ea0d45
Extract magic number -2.5px into avatarActiveOffset setting
Copilot Nov 2, 2025
ee4de2a
Fix audio cleanup in popup mode when play() fails
Copilot Nov 2, 2025
3ae0527
Merge pull request #17 from pladisdev/copilot/sub-pr-8-bf786502-ac5d-…
pladisdev Nov 2, 2025
25dc303
Extract hex opacity calculation to utility function
Copilot Nov 2, 2025
730dc88
Fix audio error handlers to clean up tracking references
Copilot Nov 2, 2025
bb266bd
Merge pull request #16 from pladisdev/copilot/sub-pr-8-4be2dd0e-06ec-…
pladisdev Nov 2, 2025
7d70a13
Merge pull request #15 from pladisdev/copilot/sub-pr-8-9e407134-0238-…
pladisdev Nov 2, 2025
d1f450d
Merge pull request #14 from pladisdev/copilot/sub-pr-8-please-work
pladisdev Nov 2, 2025
5436727
Merge pull request #13 from pladisdev/copilot/sub-pr-8-one-more-time
pladisdev Nov 2, 2025
d8c850e
Merge pull request #12 from pladisdev/copilot/sub-pr-8-yet-again
pladisdev Nov 2, 2025
1405787
Merge pull request #11 from pladisdev/copilot/sub-pr-8-another-one
pladisdev Nov 2, 2025
1ad53b1
Merge pull request #10 from pladisdev/copilot/sub-pr-8-again
pladisdev Nov 2, 2025
3956aed
Merge branch 'main' into development
pladisdev Nov 2, 2025
a0b1f82
update flow for builds
pladisdev Nov 2, 2025
a2d0cea
Merge branch 'main' into development
pladisdev Nov 2, 2025
6adf5b1
better build and release file
pladisdev Nov 2, 2025
57df8ef
workflow fix
pladisdev Nov 2, 2025
6bbabff
Merge branch 'main' into development
pladisdev Nov 2, 2025
7672520
build fix
pladisdev Nov 2, 2025
d56e12e
Merge branch 'development' of https://github.com/pladisdev/chat-yappe…
pladisdev Nov 2, 2025
5aca8cf
Merge branch 'main' into development
pladisdev Nov 2, 2025
f0cb455
usernames in chat bubbles
pladisdev Nov 6, 2025
dd0ffab
Merge branch 'development' of https://github.com/pladisdev/chat-yappe…
pladisdev Nov 6, 2025
4f534c8
twitch authentifcation
pladisdev Nov 6, 2025
9095db5
Merge branch 'main' into development
pladisdev Nov 6, 2025
979acca
tts limit
pladisdev Nov 22, 2025
008f0e0
notifcation that user needs to click page, quick status
pladisdev Nov 22, 2025
c09bdcc
update readme, version
pladisdev Nov 22, 2025
1cb53f6
Merge branch 'main' into development
pladisdev Nov 22, 2025
ee60426
emergency twitch fix
pladisdev Nov 22, 2025
56967e2
Merge branch 'main' into development
pladisdev Nov 22, 2025
cfcf2dd
Merge branch 'main' into development
pladisdev Nov 22, 2025
1aa6813
yep
pladisdev Nov 22, 2025
5844084
dang
pladisdev Nov 22, 2025
4a6a8e5
better twitch logging
pladisdev Nov 23, 2025
693bfbc
animations and avatar layout editor
pladisdev Nov 23, 2025
326928e
fix spin animation
pladisdev Nov 23, 2025
a80e583
twitch fix part 2
pladisdev Nov 23, 2025
69800d3
Merge branch 'main' into development
pladisdev Nov 23, 2025
365ef8e
twitch fix, better fonts, cleanup
pladisdev Nov 25, 2025
61d9dcf
Merge branch 'main' into development
pladisdev Nov 25, 2025
046c52f
fixed imports
pladisdev Nov 25, 2025
7b33d13
fixed things
pladisdev Nov 25, 2025
638fb4e
Merge branch 'main' into development
pladisdev Nov 25, 2025
59a7722
undefined fix
pladisdev Nov 25, 2025
4a09f0b
twitch test fix
pladisdev Nov 25, 2025
99b9642
another mock fix
pladisdev Nov 25, 2025
15efb3c
another fix for twitch creds tests
pladisdev Nov 25, 2025
6ca3832
better logging
pladisdev Nov 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 12 additions & 3 deletions backend/modules/persistent_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
20 changes: 16 additions & 4 deletions backend/modules/youtube_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
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}")
logger.info("Please install: pip install google-auth google-auth-oauthlib google-api-python-client")
YOUTUBE_AVAILABLE = False
Credentials = None # type: ignore
HttpError = Exception
RefreshError = Exception # type: ignore


class YouTubeListener:
Expand Down Expand Up @@ -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}")
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The removal of exc_info=True from this error log means the full traceback won't be logged. While this may reduce log clutter for expected errors, it could make debugging unexpected exceptions more difficult. Consider keeping exc_info=True for the catch-all Exception handler to help diagnose unforeseen issues, while omitting it for the specific RefreshError and HttpError handlers where the error context is already clear.

Copilot uses AI. Check for mistakes.
return False

async def get_live_chat_id(self) -> Optional[str]:
Expand Down Expand Up @@ -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}")
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The removal of exc_info=True from this error log means the full traceback won't be logged. While this may reduce log clutter for expected errors, it could make debugging unexpected exceptions more difficult. Consider keeping exc_info=True for the catch-all Exception handler to help diagnose unforeseen issues, while omitting it for the specific RefreshError and HttpError handlers where the error context is already clear.

Suggested change
logger.error(f"Error getting live chat ID: {e}")
logger.error(f"Error getting live chat ID: {e}", exc_info=True)

Copilot uses AI. Check for mistakes.
return None

async def listen_to_chat(self, on_event: Callable[[Dict[str, Any]], None]):
Expand Down
34 changes: 22 additions & 12 deletions backend/routers/avatars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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)}
return {"success": False, "error": str(e)}
3 changes: 2 additions & 1 deletion backend/routers/config_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/routers/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions backend/tests/test_clearchat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,24 @@
"""
import pytest
import asyncio
import os
Copy link

Copilot AI Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The os module is imported but doesn't appear to be used anywhere in this test file. Consider removing this unused import to keep the code clean.

Suggested change
import os

Copilot uses AI. Check for mistakes.
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"""

Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 = []
Expand Down
2 changes: 1 addition & 1 deletion backend/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
This file is automatically updated during CI/CD builds
"""

__version__ = "1.3.0"
__version__ = "1.3.1"
2 changes: 1 addition & 1 deletion frontend/src/components/settings/GeneralSettings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading