Skip to content

Commit c5d09d8

Browse files
pladisdevCopilotCopilot
authored
Development (#23)
* pop up mode and chat buble toggle * potential audio fix * linux sh and better docker support * fixed linux build? * linux fix part 2 * docker fix part 1 * Initial plan * Initial plan * Initial plan * Initial plan * Initial plan * Initial plan * Initial plan * Update frontend/src/pages/YappersPage.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Initial plan * Update frontend/src/pages/YappersPage.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Replace sys.platform with platform.system() for OS detection Co-authored-by: pladisdev <127021507+pladisdev@users.noreply.github.com> * Fix race condition in popup avatar lifecycle Co-authored-by: pladisdev <127021507+pladisdev@users.noreply.github.com> * Extract magic numbers to named constants for better readability Co-authored-by: pladisdev <127021507+pladisdev@users.noreply.github.com> * Extract magic number -2.5px into avatarActiveOffset setting Co-authored-by: pladisdev <127021507+pladisdev@users.noreply.github.com> * Fix audio cleanup in popup mode when play() fails Co-authored-by: pladisdev <127021507+pladisdev@users.noreply.github.com> * Extract hex opacity calculation to utility function Co-authored-by: pladisdev <127021507+pladisdev@users.noreply.github.com> * Fix audio error handlers to clean up tracking references Co-authored-by: pladisdev <127021507+pladisdev@users.noreply.github.com> * update flow for builds * better build and release file * workflow fix * build fix * usernames in chat bubbles * twitch authentifcation * tts limit * notifcation that user needs to click page, quick status * update readme, version --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 281721d commit c5d09d8

19 files changed

Lines changed: 1396 additions & 182 deletions

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,13 @@ chat-yapper/
225225

226226
## Changelog
227227

228-
### v1.2.1 (Latest)
228+
### v1.2.2 (Latest)
229229
- **New Features:**
230+
- Quick status view
231+
- Limit concurrent TTS messages
232+
- Some more twitch fixes and improved notifcations
233+
234+
### v1.2.1
230235
- usernames for chatbubbles
231236
- text size adjustment
232237
- Toggle for only allowing redeem messages for twitch

backend/app.py

Lines changed: 459 additions & 92 deletions
Large diffs are not rendered by default.

backend/modules/persistent_data.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -308,19 +308,10 @@ def save_twitch_auth(user_info: dict, token_data: dict):
308308
logger.info(f"Stored Twitch auth for user: {user_info['login']}")
309309

310310
def get_twitch_token():
311-
"""Get current Twitch token for bot connection"""
312-
from datetime import datetime
313-
311+
"""Get current Twitch token for bot connection (use get_twitch_token_for_bot for auto-refresh)"""
314312
with Session(engine) as session:
315313
auth = session.exec(select(TwitchAuth)).first()
316314
if auth:
317-
# Check if token needs refresh (if expires_at is set and in the past)
318-
if auth.expires_at:
319-
expires_at = datetime.fromisoformat(auth.expires_at)
320-
if expires_at <= datetime.now():
321-
logger.info("Twitch token expired, attempting refresh...")
322-
# TODO: Implement token refresh
323-
324315
return {
325316
"token": auth.access_token,
326317
"username": auth.username,
@@ -390,19 +381,10 @@ def save_youtube_auth(channel_info: dict, token_data: dict):
390381
logger.info(f"Stored YouTube auth for channel: {channel_info.get('snippet', {}).get('title', 'Unknown')}")
391382

392383
def get_youtube_token():
393-
"""Get current YouTube token for bot connection"""
394-
from datetime import datetime
395-
384+
"""Get current YouTube token for bot connection (use get_youtube_token_for_bot for auto-refresh)"""
396385
with Session(engine) as session:
397386
auth = session.exec(select(YouTubeAuth)).first()
398387
if auth:
399-
# Check if token needs refresh (if expires_at is set and in the past)
400-
if auth.expires_at:
401-
expires_at = datetime.fromisoformat(auth.expires_at)
402-
if expires_at <= datetime.now():
403-
logger.info("YouTube token expired, attempting refresh...")
404-
# TODO: Implement token refresh
405-
406388
return {
407389
"access_token": auth.access_token,
408390
"refresh_token": auth.refresh_token,

backend/modules/settings_defaults.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"audioFormat": "mp3",
55
"volume": 1.0,
66
"textSize": "normal",
7+
"parallelMessageLimit": 5,
8+
"queueOverflowMessages": true,
79
"avatarMode": "grid",
810
"popupDirection": "bottom",
911
"popupFixedEdge": false,

backend/modules/twitch_listener.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict
7777
client_id = ""
7878
client_secret = ""
7979

80+
# Validate that we have required credentials for TwitchIO 3.x
81+
if not client_id or not client_secret:
82+
raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}")
83+
8084
bot_id = nick
8185

8286
super().__init__(
@@ -110,6 +114,10 @@ def __init__(self, token: str, nick: str, channel: str, on_event: Callable[[Dict
110114
client_id = ""
111115
client_secret = ""
112116

117+
# Validate that we have required credentials for TwitchIO 3.x
118+
if not client_id or not client_secret:
119+
raise ValueError(f"TwitchIO 3.x requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET, but they are not configured. client_id={'present' if client_id else 'missing'}, client_secret={'present' if client_secret else 'missing'}")
120+
113121
bot_id = nick
114122
super().__init__(
115123
token=token,

backend/modules/youtube_listener.py

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ async def find_active_stream(self) -> bool:
8383
logger.warning("No active live stream found for this channel")
8484
return False
8585
except HttpError as e:
86-
logger.error(f"YouTube API error finding active stream: {e}")
86+
status_code = getattr(e, 'resp', {}).get('status', 0)
87+
if status_code == 401:
88+
logger.error("YouTube API authentication error while finding active stream - token may be expired")
89+
else:
90+
logger.error(f"YouTube API error finding active stream: {e}")
8791
return False
8892
except Exception as e:
8993
logger.error(f"Error finding active stream: {e}", exc_info=True)
@@ -119,7 +123,11 @@ async def get_live_chat_id(self) -> Optional[str]:
119123
logger.error(f"Video {self.video_id} not found")
120124
return None
121125
except HttpError as e:
122-
logger.error(f"YouTube API error getting live chat ID: {e}")
126+
status_code = getattr(e, 'resp', {}).get('status', 0)
127+
if status_code == 401:
128+
logger.error("YouTube API authentication error while getting live chat ID - token may be expired")
129+
else:
130+
logger.error(f"YouTube API error getting live chat ID: {e}")
123131
return None
124132
except Exception as e:
125133
logger.error(f"Error getting live chat ID: {e}", exc_info=True)
@@ -289,17 +297,47 @@ async def listen_to_chat(self, on_event: Callable[[Dict[str, Any]], None]):
289297
error_reason = getattr(e, 'reason', 'Unknown error')
290298
status_code = getattr(e, 'resp', {}).get('status', 0)
291299

292-
if status_code == 403:
293-
logger.error("⚠️ YouTube API quota exceeded or access forbidden")
294-
logger.error(" The YouTube Data API has strict quota limits:")
295-
logger.error(" - Default quota: 10,000 units/day")
296-
logger.error(" - Each chat poll costs ~5 units")
297-
logger.error(" - This allows ~2,000 polls/day (~83/hour or ~1.4/minute)")
298-
logger.error(" Pausing for 5 minutes to avoid further quota usage...")
299-
await asyncio.sleep(300) # Wait 5 minutes on quota errors
300-
# After quota error, slow down significantly
301-
self.polling_interval = self.max_polling_interval
302-
self.consecutive_empty_polls = 10 # Force slow polling
300+
if status_code == 401:
301+
logger.warning("YouTube API authentication error - token may be expired")
302+
logger.info("Attempting to refresh credentials and rebuild YouTube client...")
303+
304+
# Try to refresh credentials using the auth router function
305+
try:
306+
from routers.auth import get_youtube_token_for_bot
307+
token_info = await get_youtube_token_for_bot()
308+
if token_info and token_info.get('credentials'):
309+
# Rebuild YouTube client with refreshed credentials
310+
from googleapiclient.discovery import build
311+
self.credentials = token_info['credentials']
312+
self.youtube = build('youtube', 'v3', credentials=self.credentials)
313+
logger.info("Successfully refreshed YouTube credentials and rebuilt client")
314+
continue # Retry the request
315+
else:
316+
logger.error("Failed to refresh YouTube credentials - stopping listener")
317+
self.running = False
318+
break
319+
except Exception as refresh_error:
320+
logger.error(f"Error during YouTube credential refresh: {refresh_error}")
321+
await asyncio.sleep(30) # Wait before retrying
322+
323+
elif status_code == 403:
324+
error_details = str(e)
325+
if "quotaExceeded" in error_details or "quota" in error_reason.lower():
326+
logger.error("⚠️ YouTube API quota exceeded")
327+
logger.error(" The YouTube Data API has strict quota limits:")
328+
logger.error(" - Default quota: 10,000 units/day")
329+
logger.error(" - Each chat poll costs ~5 units")
330+
logger.error(" - This allows ~2,000 polls/day (~83/hour or ~1.4/minute)")
331+
logger.error(" Pausing for 5 minutes to avoid further quota usage...")
332+
await asyncio.sleep(300) # Wait 5 minutes on quota errors
333+
# After quota error, slow down significantly
334+
self.polling_interval = self.max_polling_interval
335+
self.consecutive_empty_polls = 10 # Force slow polling
336+
else:
337+
logger.error("⚠️ YouTube API access forbidden - check permissions")
338+
logger.error(f" Error details: {error_reason}")
339+
await asyncio.sleep(60) # Wait 1 minute for permission errors
340+
303341
elif status_code == 404:
304342
logger.error("Live chat not found - stream may have ended")
305343
self.running = False

backend/routers/auth.py

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,41 @@ async def youtube_auth_status():
604604
logger.error(f"Error checking YouTube status: {e}")
605605
return {"connected": False, "error": str(e)}
606606

607+
@router.post("/api/youtube/refresh-token")
608+
async def refresh_youtube_token_endpoint():
609+
"""Manually refresh the YouTube access token"""
610+
try:
611+
auth = get_youtube_auth()
612+
if not auth:
613+
return {"success": False, "error": "No YouTube account connected"}
614+
615+
if not auth.refresh_token:
616+
return {"success": False, "error": "No refresh token available - please reconnect your account"}
617+
618+
logger.info("Manual YouTube token refresh requested")
619+
refreshed_token_data = await refresh_youtube_token(auth.refresh_token)
620+
621+
if refreshed_token_data:
622+
# Get updated channel info
623+
channel_info = await get_youtube_channel_info(refreshed_token_data["access_token"])
624+
if channel_info:
625+
# Store the refreshed token
626+
await store_youtube_auth(channel_info, refreshed_token_data)
627+
logger.info("Successfully refreshed YouTube token manually")
628+
629+
return {
630+
"success": True,
631+
"message": f"Token refreshed successfully for {channel_info.get('snippet', {}).get('title', 'Unknown')}"
632+
}
633+
else:
634+
return {"success": False, "error": "Failed to get channel info after token refresh"}
635+
else:
636+
return {"success": False, "error": "Failed to refresh token - may need to reconnect your account"}
637+
638+
except Exception as e:
639+
logger.error(f"Error refreshing YouTube token: {e}", exc_info=True)
640+
return {"success": False, "error": str(e)}
641+
607642
@router.delete("/api/youtube/disconnect")
608643
async def youtube_disconnect():
609644
"""Disconnect YouTube account"""
@@ -681,11 +716,96 @@ async def store_youtube_auth(channel_info: Dict[str, Any], token_data: Dict[str,
681716
logger.error(f"Error storing YouTube auth: {e}")
682717
raise
683718

719+
async def refresh_youtube_token(refresh_token: str) -> Dict[str, Any]:
720+
"""Refresh an expired YouTube access token"""
721+
try:
722+
data = {
723+
"client_id": YOUTUBE_CLIENT_ID,
724+
"client_secret": YOUTUBE_CLIENT_SECRET,
725+
"grant_type": "refresh_token",
726+
"refresh_token": refresh_token
727+
}
728+
729+
async with aiohttp.ClientSession() as session:
730+
async with session.post("https://oauth2.googleapis.com/token", data=data) as response:
731+
if response.status == 200:
732+
result = await response.json()
733+
logger.info("Successfully refreshed YouTube token")
734+
return result
735+
else:
736+
error_text = await response.text()
737+
logger.error(f"YouTube token refresh failed: {response.status} - {error_text}")
738+
return None
739+
except Exception as e:
740+
logger.error(f"Error refreshing YouTube token: {e}")
741+
return None
742+
684743
async def get_youtube_token_for_bot():
685-
"""Get current YouTube token for bot connection"""
744+
"""Get current YouTube token for bot connection with automatic refresh"""
686745
try:
687-
return get_youtube_token()
746+
from google.oauth2.credentials import Credentials
747+
748+
auth = get_youtube_auth()
749+
if not auth:
750+
return None
751+
752+
# Check if token needs refresh (if expires_at is set and in the past)
753+
needs_refresh = False
754+
current_token = auth.access_token
755+
current_refresh_token = auth.refresh_token
756+
757+
if auth.expires_at:
758+
try:
759+
expires_at = datetime.fromisoformat(auth.expires_at)
760+
# Add 5-minute buffer to refresh before actual expiration
761+
buffer_time = expires_at - timedelta(minutes=5)
762+
if datetime.now() >= buffer_time:
763+
needs_refresh = True
764+
logger.info(f"YouTube token expires at {expires_at}, refreshing with 5-minute buffer")
765+
except ValueError as e:
766+
logger.warning(f"Invalid expires_at format: {auth.expires_at}, will attempt refresh: {e}")
767+
needs_refresh = True
768+
769+
# Attempt token refresh if needed and refresh token is available
770+
if needs_refresh and auth.refresh_token:
771+
logger.info("Attempting to refresh YouTube token...")
772+
773+
refreshed_token_data = await refresh_youtube_token(auth.refresh_token)
774+
if refreshed_token_data:
775+
# Get updated channel info to ensure account is still valid
776+
channel_info = await get_youtube_channel_info(refreshed_token_data["access_token"])
777+
if channel_info:
778+
# Store the refreshed token
779+
await store_youtube_auth(channel_info, refreshed_token_data)
780+
logger.info("Successfully refreshed and stored new YouTube token")
781+
782+
# Use refreshed token data
783+
current_token = refreshed_token_data["access_token"]
784+
current_refresh_token = refreshed_token_data.get("refresh_token", auth.refresh_token)
785+
else:
786+
logger.error("Failed to get channel info after YouTube token refresh")
787+
else:
788+
logger.error("Failed to refresh YouTube token - may need to re-authenticate")
789+
return None
790+
791+
# Create Google OAuth2 credentials object
792+
credentials = Credentials(
793+
token=current_token,
794+
refresh_token=current_refresh_token,
795+
token_uri="https://oauth2.googleapis.com/token",
796+
client_id=YOUTUBE_CLIENT_ID,
797+
client_secret=YOUTUBE_CLIENT_SECRET
798+
)
799+
800+
# Return token info with credentials
801+
return {
802+
"access_token": current_token,
803+
"refresh_token": current_refresh_token,
804+
"channel_id": auth.channel_id,
805+
"channel_name": auth.channel_name,
806+
"credentials": credentials
807+
}
808+
688809
except Exception as e:
689810
logger.error(f"Error getting YouTube token: {e}")
690-
691-
return None
811+
return None

0 commit comments

Comments
 (0)