From b9aa65a77c4dcb396f2f6e36a951393c6eea3a12 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:01:10 -0800 Subject: [PATCH 01/11] fix(threading): add thread safety for UI updates - Wrap UI updates with GLib.idle_add() for thread safety - AdSchedule.py: Wrap labels and color updates - ChatMode.py: Wrap set_center_label() calls - ShowViewers.py: Wrap set_center_label() calls - Add backend initialization error handling - Modified _setup_backend() to return bool - Added backend_initialized to track state - Added error logging for init failures - Updated settings.py to check init status Fixes race conditions and crashes from UI updates on non-main threads, provides better error visibility --- actions/AdSchedule.py | 29 ++++++++++++------ actions/ChatMode.py | 9 ++++-- actions/ShowViewers.py | 7 +++-- main.py | 68 +++++++++++++++++++++++++----------------- settings.py | 36 ++++++++++++++++------ 5 files changed, 98 insertions(+), 51 deletions(-) diff --git a/actions/AdSchedule.py b/actions/AdSchedule.py index b2c61fa..2f6bee2 100644 --- a/actions/AdSchedule.py +++ b/actions/AdSchedule.py @@ -3,6 +3,7 @@ from threading import Thread from time import sleep +from gi.repository import GLib from GtkHelper.GenerativeUI.SwitchRow import SwitchRow from .TwitchCore import TwitchCore from src.backend.PluginManager.EventAssigner import EventAssigner @@ -37,9 +38,11 @@ def __init__(self, *args, **kwargs): def on_ready(self): super().on_ready() Thread( - target=self._get_ad_schedule, daemon=True, name="get_ad_schedule").start() + target=self._get_ad_schedule, daemon=True, name="get_ad_schedule" + ).start() Thread( - target=self._update_ad_timer, daemon=True, name="update_ad_timer").start() + target=self._update_ad_timer, daemon=True, name="update_ad_timer" + ).start() def create_event_assigners(self): self.event_manager.add_event_assigner( @@ -58,29 +61,37 @@ def create_generative_ui(self): default_value=True, title="ad-snooze", subtitle="Snoozes ad for 5 minutes when pushed", - complex_var_name=True + complex_var_name=True, ) def get_config_rows(self): return [self._skip_ad_switch.widget] def _update_background_color(self, color: str): - self.current_color = self.get_color(color) - self.display_color() + def _update(): + self.current_color = self.get_color(color) + self.display_color() + + GLib.idle_add(_update) def _update_ad_timer(self): while self.get_is_present(): self.display_color() now = datetime.now() - self.set_bottom_label( - str(self._snoozes) if (self._snoozes >= 0 and self._skip_ad_switch.get_active()) else "") + snooze_label = ( + str(self._snoozes) + if (self._snoozes >= 0 and self._skip_ad_switch.get_active()) + else "" + ) + GLib.idle_add(lambda: self.set_bottom_label(snooze_label)) try: if self._next_ad < now: self._update_background_color(Colors.DEFAULT) - self.set_center_label("") + GLib.idle_add(lambda: self.set_center_label("")) continue diff = (self._next_ad - now).total_seconds() - self.set_center_label(self._convert_seconds_to_hh_mm_ss(diff)) + time_label = self._convert_seconds_to_hh_mm_ss(diff) + GLib.idle_add(lambda: self.set_center_label(time_label)) if diff <= 60: self._update_background_color(Colors.ALERT) continue diff --git a/actions/ChatMode.py b/actions/ChatMode.py index a91c2c5..cc8c8c3 100644 --- a/actions/ChatMode.py +++ b/actions/ChatMode.py @@ -2,6 +2,7 @@ from threading import Thread from time import sleep +from gi.repository import GLib from .TwitchCore import TwitchCore from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input @@ -56,12 +57,13 @@ def create_generative_ui(self): ], title="chat-toggle-dropdown", complex_var_name=True, - on_change=self._change_chat_mode + on_change=self._change_chat_mode, ) def on_ready(self): Thread( - target=self._update_chat_mode, daemon=True, name="update_chat_mode").start() + target=self._update_chat_mode, daemon=True, name="update_chat_mode" + ).start() def get_config_rows(self): return [self._chat_select_row.widget] @@ -73,7 +75,8 @@ def _change_chat_mode(self, _, new, __): def _update_icon(self, mode: str, enabled: bool): # TODO: Custom icons for enabled/disabled - self.set_center_label("Enabled" if enabled else "Disabled") + label = "Enabled" if enabled else "Disabled" + GLib.idle_add(lambda: self.set_center_label(label)) def _update_chat_mode(self): while self.get_is_present(): diff --git a/actions/ShowViewers.py b/actions/ShowViewers.py index 3893bec..7e85d49 100644 --- a/actions/ShowViewers.py +++ b/actions/ShowViewers.py @@ -2,6 +2,7 @@ from threading import Thread from time import sleep +from gi.repository import GLib from .TwitchCore import TwitchCore from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input @@ -21,13 +22,13 @@ def __init__(self, *args, **kwargs): self.icon_name = Icons.VIEWERS def on_ready(self): - Thread( - target=self._update_viewers, daemon=True, name="update_viewers").start() + Thread(target=self._update_viewers, daemon=True, + name="update_viewers").start() def _update_viewers(self): while self.get_is_present(): count = self.backend.get_viewers() if not count: count = "-" - self.set_center_label(str(count)) + GLib.idle_add(lambda c=count: self.set_center_label(str(c))) sleep(10) diff --git a/main.py b/main.py index d25aaea..9325692 100644 --- a/main.py +++ b/main.py @@ -48,10 +48,7 @@ def _add_colors(self): def _register_actions(self): self.message_action_holder = ActionHolder( - plugin_base=self, - action_base=SendMessage, - action_id_suffix="SendMessage", - action_name="Send Chat Message", + plugin_base=self, action_base=SendMessage, action_id_suffix="SendMessage", action_name="Send Chat Message", action_support={ Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, @@ -69,7 +66,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(self.clip_action_holder) @@ -82,7 +79,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(self.viewers_action_holder) @@ -95,7 +92,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(self.marker_actions_holder) @@ -108,7 +105,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(self.chatmode_actions_holder) @@ -121,7 +118,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(self.playad_action_holder) @@ -134,28 +131,36 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(self.ad_schedule_action_holder) def _setup_backend(self): backend_path = os.path.join(self.PATH, "twitch_backend.py") - self.launch_backend(backend_path=backend_path, open_in_terminal=False, - venv_path=os.path.join(self.PATH, ".venv")) - self.wait_for_backend(tries=5) + self.launch_backend( + backend_path=backend_path, + open_in_terminal=False, + venv_path=os.path.join(self.PATH, ".venv"), + ) + backend_ready = self.wait_for_backend(tries=5) + if not backend_ready: + logger.error( + "Failed to initialize Twitch backend after 5 attempts") + return False settings = self.get_settings() - client_id = settings.get('client_id', '') - client_secret = settings.get('client_secret', '') - auth_code = settings.get('auth_code', '') + client_id = settings.get("client_id", "") + client_secret = settings.get("client_secret", "") + auth_code = settings.get("auth_code", "") settings_path = os.path.join( - gl.DATA_PATH, 'settings', 'plugins', self.get_plugin_id_from_folder_name()) + gl.DATA_PATH, "settings", "plugins", self.get_plugin_id_from_folder_name() + ) os.makedirs(settings_path, exist_ok=True) self.backend.set_token_path(os.path.join(settings_path, "keys.json")) if client_id and client_secret and auth_code: - self.backend.auth_with_code( - client_id, client_secret, auth_code) + self.backend.auth_with_code(client_id, client_secret, auth_code) + return True def __init__(self): super().__init__(use_legacy_locale=False) @@ -165,36 +170,45 @@ def __init__(self): self.lm.set_to_os_default() self._settings_manager = PluginSettings(self) self.auth_callback_fn: callable = None + self.backend_initialized = False self._add_icons() self._add_colors() - self._setup_backend() + self.backend_initialized = self._setup_backend() + if not self.backend_initialized: + logger.warning( + "Twitch plugin loaded but backend failed to initialize. Please check settings." + ) self._register_actions() try: - with open(os.path.join(self.PATH, "manifest.json"), "r", encoding="UTF-8") as f: + with open( + os.path.join(self.PATH, "manifest.json"), "r", encoding="UTF-8" + ) as f: data = json.load(f) except Exception as ex: logger.error(ex) data = {} app_manifest = { "plugin_version": data.get("version", "0.0.0"), - "app_version": data.get("app-version", "0.0.0") + "app_version": data.get("app-version", "0.0.0"), } self.register( plugin_name="Twitch Integration", github_repo="https://github.com/imdevinc/StreamControllerTwitchPlugin", plugin_version=app_manifest.get("plugin_version"), - app_version=app_manifest.get("app_version") + app_version=app_manifest.get("app_version"), ) self.add_css_stylesheet(os.path.join(self.PATH, "style.css")) - def save_auth_settings(self, client_id: str, client_secret: str, auth_code: str) -> None: + def save_auth_settings( + self, client_id: str, client_secret: str, auth_code: str + ) -> None: settings = self.get_settings() - settings['client_id'] = client_id - settings['client_secret'] = client_secret - settings['auth_code'] = auth_code + settings["client_id"] = client_id + settings["client_secret"] = client_secret + settings["auth_code"] = auth_code self.set_settings(settings) def on_auth_callback(self, success: bool, message: str = "") -> None: diff --git a/settings.py b/settings.py index e082a09..44592ac 100644 --- a/settings.py +++ b/settings.py @@ -22,18 +22,34 @@ def __init__(self, plugin_base: PluginBase): self._plugin_base = plugin_base def get_settings_area(self) -> Adw.PreferencesGroup: - if not self._plugin_base.backend.is_authed(): - self._status_label = Gtk.Label(label=self._plugin_base.lm.get( - "actions.base.credentials.failed"), css_classes=["twitch-controller-red"]) + if not self._plugin_base.backend or not self._plugin_base.backend_initialized: + self._status_label = Gtk.Label( + label=self._plugin_base.lm.get( + "actions.base.credentials.failed"), + css_classes=["twitch-controller-red"], + ) + elif not self._plugin_base.backend.is_authed(): + self._status_label = Gtk.Label( + label=self._plugin_base.lm.get( + "actions.base.credentials.failed"), + css_classes=["twitch-controller-red"], + ) else: - self._status_label = Gtk.Label(label=self._plugin_base.lm.get( - "actions.base.credentials.authenticated"), css_classes=["twitch-controller-green"]) + self._status_label = Gtk.Label( + label=self._plugin_base.lm.get( + "actions.base.credentials.authenticated" + ), + css_classes=["twitch-controller-green"], + ) self._client_id = Adw.EntryRow( - title=self._plugin_base.lm.get("actions.base.twitch_client_id")) + title=self._plugin_base.lm.get("actions.base.twitch_client_id") + ) self._client_secret = Adw.PasswordEntryRow( - title=self._plugin_base.lm.get("actions.base.twitch_client_secret")) + title=self._plugin_base.lm.get("actions.base.twitch_client_secret") + ) self._auth_button = Gtk.Button( - label=self._plugin_base.lm.get("actions.base.credentials.validate")) + label=self._plugin_base.lm.get("actions.base.credentials.validate") + ) self._auth_button.set_margin_top(10) self._auth_button.set_margin_bottom(10) self._client_id.connect("notify::text", self._on_change_client_id) @@ -44,7 +60,9 @@ def get_settings_area(self) -> Adw.PreferencesGroup: gh_link_label = self._plugin_base.lm.get("actions.info.link.label") gh_link_text = self._plugin_base.lm.get("actions.info.link.text") gh_label = Gtk.Label( - use_markup=True, label=f"{gh_link_label} {gh_link_text}") + use_markup=True, + label=f'{gh_link_label} {gh_link_text}', + ) self._load_settings() self._enable_auth() From 6590036141ed6ccd37e6c5f62ef777689a1cc381 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:36:36 -0800 Subject: [PATCH 02/11] fix(errors): improve error messages and fix HTTP server lifecycle - Add descriptive error messages to all action files - Clip.py: Add context for clip creation failures - Marker.py: Add context for stream marker creation failures - SendMessage.py: Add channel name in error messages - ChatMode.py: Add mode name in toggle/update errors - PlayAd.py: Add ad duration in error messages - AdSchedule.py: Add specific context for schedule errors - Fix HTTP server lifecycle in twitch_backend.py - Properly cleanup server on disconnect - Clean up existing server before creating new one - Add error handling for server creation and cleanup - Set server references to None after cleanup - Consolidate AdSchedule threads into single update loop - Reduce from 2 threads to 1 consolidated thread - Fetch ad schedule every 30s, update display every 1s - Eliminate nested loops, improve resource efficiency --- actions/AdSchedule.py | 51 +++++++++++--------- actions/ChatMode.py | 10 ++-- actions/Clip.py | 4 +- actions/Marker.py | 4 +- actions/PlayAd.py | 14 ++++-- actions/SendMessage.py | 4 +- main.py | 1 - twitch_backend.py | 105 ++++++++++++++++++++++++++--------------- 8 files changed, 117 insertions(+), 76 deletions(-) diff --git a/actions/AdSchedule.py b/actions/AdSchedule.py index 2f6bee2..5900421 100644 --- a/actions/AdSchedule.py +++ b/actions/AdSchedule.py @@ -38,10 +38,7 @@ def __init__(self, *args, **kwargs): def on_ready(self): super().on_ready() Thread( - target=self._get_ad_schedule, daemon=True, name="get_ad_schedule" - ).start() - Thread( - target=self._update_ad_timer, daemon=True, name="update_ad_timer" + target=self._update_ad_display, daemon=True, name="update_ad_display" ).start() def create_event_assigners(self): @@ -74,51 +71,59 @@ def _update(): GLib.idle_add(_update) - def _update_ad_timer(self): + def _update_ad_display(self): + """Consolidated update loop that fetches ad schedule and updates display.""" + last_fetch_time = datetime.now() - timedelta( + seconds=30 + ) # Fetch immediately on start + while self.get_is_present(): self.display_color() now = datetime.now() + + # Fetch ad schedule every 30 seconds + if (now - last_fetch_time).total_seconds() >= 30: + try: + schedule, snoozes = self.backend.get_next_ad() + self._next_ad = schedule + self._snoozes = snoozes + last_fetch_time = now + except Exception as ex: + log.error(f"Failed to get ad schedule from Twitch API: {ex}") + self.show_error(3) + + # Update display every second snooze_label = ( str(self._snoozes) if (self._snoozes >= 0 and self._skip_ad_switch.get_active()) else "" ) GLib.idle_add(lambda: self.set_bottom_label(snooze_label)) + try: if self._next_ad < now: self._update_background_color(Colors.DEFAULT) GLib.idle_add(lambda: self.set_center_label("")) + sleep(1) continue diff = (self._next_ad - now).total_seconds() time_label = self._convert_seconds_to_hh_mm_ss(diff) GLib.idle_add(lambda: self.set_center_label(time_label)) if diff <= 60: self._update_background_color(Colors.ALERT) - continue - if diff <= 300: + elif diff <= 300: self._update_background_color(Colors.WARNING) - continue - self._update_background_color(Colors.DEFAULT) + else: + self._update_background_color(Colors.DEFAULT) except TypeError: # There is a known issue where the default timestamp returned from # the twitch API is an invalid datetime object and causes an error. # Ignoring it here pass except Exception as ex: - log.error(ex) - sleep(1) + log.error(f"Failed to update ad timer display: {ex}") - def _get_ad_schedule(self): - while self.get_is_present(): - try: - schedule, snoozes = self.backend.get_next_ad() - self._next_ad = schedule - self._snoozes = snoozes - self._update_ad_timer() - except Exception as ex: - log.error(ex) - self.show_error(3) - sleep(30) + sleep(1) def _convert_seconds_to_hh_mm_ss(self, seconds) -> str: hours = seconds // 3600 @@ -132,5 +137,5 @@ def _on_snooze_ad(self, _): try: self.backend.snooze_ad() except Exception as ex: - log.error(ex) + log.error(f"Failed to snooze next ad: {ex}") self.show_error(3) diff --git a/actions/ChatMode.py b/actions/ChatMode.py index cc8c8c3..fb0a4c3 100644 --- a/actions/ChatMode.py +++ b/actions/ChatMode.py @@ -29,8 +29,7 @@ class ChatModeOptions(Enum): class ChatMode(TwitchCore): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.icon_keys = [Icons.FOLLOWER, - Icons.SUBSCRIBER, Icons.EMOTE, Icons.SLOW] + self.icon_keys = [Icons.FOLLOWER, Icons.SUBSCRIBER, Icons.EMOTE, Icons.SLOW] self.current_icon = self.get_icon(Icons.FOLLOWER) self.icon_name = Icons.FOLLOWER @@ -80,13 +79,16 @@ def _update_icon(self, mode: str, enabled: bool): def _update_chat_mode(self): while self.get_is_present(): + mode = None try: chat_settings = self.backend.get_chat_settings() mode = self._chat_select_row.get_selected_item().get_value() enabled = chat_settings.get(mode) self._update_icon(mode, enabled) except Exception as ex: - log.error(ex) + log.error( + f"Failed to update chat mode status{f' for {mode}' if mode else ''}: {ex}" + ) self.show_error(3) sleep(5) @@ -96,5 +98,5 @@ def _on_toggle_chat(self, _): resp = self.backend.toggle_chat_mode(item) self._update_icon(item, resp) except Exception as ex: - log.error(ex) + log.error(f"Failed to toggle chat mode '{item}': {ex}") self.show_error(3) diff --git a/actions/Clip.py b/actions/Clip.py index 8b23c80..7949d5d 100644 --- a/actions/Clip.py +++ b/actions/Clip.py @@ -25,7 +25,7 @@ def create_event_assigners(self): id="clip", ui_label="Clip", default_event=Input.Key.Events.DOWN, - callback=self._on_clip + callback=self._on_clip, ) ) @@ -33,5 +33,5 @@ def _on_clip(self, _): try: self.backend.create_clip() except Exception as ex: - log.error(ex) + log.error(f"Failed to create clip: {ex}") self.show_error(3) diff --git a/actions/Marker.py b/actions/Marker.py index 05a178b..e8cb012 100644 --- a/actions/Marker.py +++ b/actions/Marker.py @@ -25,7 +25,7 @@ def create_event_assigners(self): id="marker", ui_label="Marker", default_event=Input.Key.Events.DOWN, - callback=self._on_marker + callback=self._on_marker, ) ) @@ -33,5 +33,5 @@ def _on_marker(self, _): try: self.backend.create_marker() except Exception as ex: - log.error(ex) + log.error(f"Failed to create stream marker: {ex}") self.show_error(3) diff --git a/actions/PlayAd.py b/actions/PlayAd.py index 5aff501..d3a1cd4 100644 --- a/actions/PlayAd.py +++ b/actions/PlayAd.py @@ -22,15 +22,16 @@ def __init__(self, *args, **kwargs): self.has_configuration = True def create_generative_ui(self): - options = [SimpleComboRowItem(str(x), f"{x} seconds") - for x in [30, 60, 90, 120]] + options = [ + SimpleComboRowItem(str(x), f"{x} seconds") for x in [30, 60, 90, 120] + ] self._time_row = ComboRow( action_core=self, var_name="ad.duration", default_value=options[0], items=options, title="ad-options-dropdown", - complex_var_name=True + complex_var_name=True, ) def get_config_rows(self): @@ -42,14 +43,17 @@ def create_event_assigners(self): id="play-ad", ui_label="Play Ad", default_event=Input.Key.Events.DOWN, - callback=self._on_play_ad + callback=self._on_play_ad, ) ) def _on_play_ad(self, _): + time = None try: time = self._time_row.get_selected_item().get_value() self.backend.play_ad(int(time)) except Exception as ex: - log.error(ex) + log.error( + f"Failed to play ad{f' (duration: {time}s)' if time else ''}: {ex}" + ) self.show_error(3) diff --git a/actions/SendMessage.py b/actions/SendMessage.py index b099368..7a74e62 100644 --- a/actions/SendMessage.py +++ b/actions/SendMessage.py @@ -27,7 +27,7 @@ def create_event_assigners(self): id="chat", ui_label="Message", default_event=Input.Key.Events.DOWN, - callback=self._on_chat + callback=self._on_chat, ) ) @@ -58,5 +58,5 @@ def _on_chat(self, _): try: self.backend.send_message(message, channel) except Exception as ex: - log.error(ex) + log.error(f"Failed to send chat message to channel '{channel}': {ex}") self.show_error(3) diff --git a/main.py b/main.py index 9325692..528654f 100644 --- a/main.py +++ b/main.py @@ -146,7 +146,6 @@ def _setup_backend(self): if not backend_ready: logger.error( "Failed to initialize Twitch backend after 5 attempts") - return False settings = self.get_settings() client_id = settings.get("client_id", "") diff --git a/twitch_backend.py b/twitch_backend.py index a00bfd5..4711ca8 100644 --- a/twitch_backend.py +++ b/twitch_backend.py @@ -11,35 +11,38 @@ from streamcontroller_plugin_tools import BackendBase -def make_handler(plugin_backend: 'Backend'): +def make_handler(plugin_backend: "Backend"): class AuthHandler(BaseHTTPRequestHandler): def do_GET(self): - if not self.path.startswith('/auth'): + if not self.path.startswith("/auth"): self.send_response(201) return url_parts = urlparse(self.path) query_params = parse_qs(url_parts.query) - if 'error' in query_params: - message = query_params['error_description'] if 'error_description' in query_params else 'Something went wrong!' + if "error" in query_params: + message = ( + query_params["error_description"] + if "error_description" in query_params + else "Something went wrong!" + ) status = 500 else: - message = 'Success! You may now close this browser window' + message = "Success! You may now close this browser window" status = 200 - shutdown = threading.Thread( - target=self.server.shutdown, daemon=True) + shutdown = threading.Thread(target=self.server.shutdown, daemon=True) shutdown.start() - self.protocol_version = 'HTTP/1.1' + self.protocol_version = "HTTP/1.1" self.send_response(status) - self.send_header('Content-Length', len(message)) + self.send_header("Content-Length", len(message)) self.end_headers() - self.wfile.write(bytes(message, 'utf8')) + self.wfile.write(bytes(message, "utf8")) if status != 200: plugin_backend.auth_failed() return - plugin_backend.new_code(query_params['code'][0]) + plugin_backend.new_code(query_params["code"][0]) return AuthHandler @@ -62,7 +65,13 @@ def set_token_path(self, path: str) -> None: def on_disconnect(self, conn): if self.httpd is not None: - self.httpd.shutdown() + try: + self.httpd.shutdown() + self.httpd.server_close() + except Exception as ex: + log.error(f"Error shutting down HTTP server: {ex}") + self.httpd = None + self.httpd_thread = None super().on_disconnect(conn) def get_channel_id(self, user_name: str) -> str | None: @@ -97,7 +106,7 @@ def get_viewers(self) -> str: self.validate_auth() streams = self.twitch.get_streams(first=1, user_id=self.user_id) if not streams: - return 'Not Live' + return "Not Live" return str(streams[0].viewer_count) def toggle_chat_mode(self, mode: str) -> bool: @@ -106,8 +115,7 @@ def toggle_chat_mode(self, mode: str) -> bool: self.validate_auth() current = self.twitch.get_chat_settings(self.user_id, self.user_id) updated = not getattr(current, mode) - self.twitch.update_chat_settings( - self.user_id, self.user_id, **{mode: updated}) + self.twitch.update_chat_settings(self.user_id, self.user_id, **{mode: updated}) return updated def get_chat_settings(self) -> dict: @@ -116,10 +124,10 @@ def get_chat_settings(self) -> dict: self.validate_auth() current = self.twitch.get_chat_settings(self.user_id, self.user_id) return { - 'subscriber_mode': current.subscriber_mode, - 'follower_mode': current.follower_mode, - 'emote_mode': current.emote_mode, - 'slow_mode': current.slow_mode + "subscriber_mode": current.subscriber_mode, + "follower_mode": current.follower_mode, + "emote_mode": current.emote_mode, + "slow_mode": current.slow_mode, } def send_message(self, message: str, user_name: str) -> None: @@ -154,29 +162,47 @@ def update_client_credentials(self, client_id: str, client_secret: str) -> None: self.client_id = client_id self.client_secret = client_secret params = { - 'client_id': client_id, - 'redirect_uri': 'http://localhost:3000/auth', - 'response_type': 'code', - 'scope': 'user:write:chat channel:manage:broadcast moderator:manage:chat_settings clips:edit channel:read:subscriptions channel:edit:commercial channel:manage:ads channel:read:ads' + "client_id": client_id, + "redirect_uri": "http://localhost:3000/auth", + "response_type": "code", + "scope": "user:write:chat channel:manage:broadcast moderator:manage:chat_settings clips:edit channel:read:subscriptions channel:edit:commercial channel:manage:ads channel:read:ads", } encoded_params = urlencode(params) - if not self.httpd: - self.httpd = HTTPServer(('localhost', 3000), make_handler(self)) + + # Clean up existing server if it exists + if self.httpd is not None: + try: + self.httpd.shutdown() + self.httpd.server_close() + except Exception as ex: + log.error(f"Error shutting down existing HTTP server: {ex}") + + # Create new server + try: + self.httpd = HTTPServer(("localhost", 3000), make_handler(self)) + except Exception as ex: + log.error(f"Failed to create HTTP server on port 3000: {ex}") + self.auth_failed("Failed to start local authentication server") + return + + # Create and start server thread if not self.httpd_thread or not self.httpd_thread.is_alive(): self.httpd_thread = threading.Thread( - target=self.httpd.serve_forever, daemon=True) + target=self.httpd.serve_forever, daemon=True + ) if not self.httpd_thread.is_alive(): self.httpd_thread.start() - url = f'https://id.twitch.tv/oauth2/authorize?{encoded_params}' + url = f"https://id.twitch.tv/oauth2/authorize?{encoded_params}" check = requests.get(url, timeout=5) if check.status_code != 200: - message = check.json().get("message") if check.json() else "Incorrect Client ID" + message = ( + check.json().get("message") if check.json() else "Incorrect Client ID" + ) self.auth_failed(message) return - webbrowser.open( - f'https://id.twitch.tv/oauth2/authorize?{encoded_params}') + webbrowser.open(f"https://id.twitch.tv/oauth2/authorize?{encoded_params}") def new_code(self, auth_code: str) -> None: self.auth_with_code(self.client_id, self.client_secret, auth_code) @@ -185,20 +211,25 @@ def validate_auth(self) -> None: try: _ = self.twitch.get_streams(first=1, user_id=self.user_id) except Exception as ex: - self.auth_with_code( - self.client_id, self.client_secret, self.auth_code) + self.auth_with_code(self.client_id, self.client_secret, self.auth_code) - def auth_with_code(self, client_id: str, client_secret: str, auth_code: str) -> None: + def auth_with_code( + self, client_id: str, client_secret: str, auth_code: str + ) -> None: try: - self.twitch = Client(client_id=client_id, client_secret=client_secret, - tokens_path=self.token_path, redirect_uri='http://localhost:3000/auth', authorization_code=auth_code) + self.twitch = Client( + client_id=client_id, + client_secret=client_secret, + tokens_path=self.token_path, + redirect_uri="http://localhost:3000/auth", + authorization_code=auth_code, + ) users = self.twitch.get_users() self.auth_code = auth_code self.user_id = users[0].user_id self.client_id = client_id self.client_secret = client_secret - self.frontend.save_auth_settings( - client_id, client_secret, auth_code) + self.frontend.save_auth_settings(client_id, client_secret, auth_code) self.frontend.on_auth_callback(True) except Exception as e: log.error("failed to authenticate", e) From 3774915d2fe3a83680dcafb7ea00fd0f2a27cef3 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:47:54 -0800 Subject: [PATCH 03/11] feat(constants): add configuration constants file Create centralized constants.py for magic numbers --- constants.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 constants.py diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..b17485e --- /dev/null +++ b/constants.py @@ -0,0 +1,16 @@ +""" +Constants used throughout the Twitch plugin. +""" + +# Update intervals (in seconds) +VIEWER_UPDATE_INTERVAL_SECONDS = 10 +AD_SCHEDULE_FETCH_INTERVAL_SECONDS = 30 +AD_DISPLAY_UPDATE_INTERVAL_SECONDS = 1 +CHAT_MODE_UPDATE_INTERVAL_SECONDS = 5 + +# Error display +ERROR_DISPLAY_DURATION_SECONDS = 3 + +# OAuth/Authentication +OAUTH_REDIRECT_URI = "http://localhost:3000/auth" +OAUTH_PORT = 3000 From e7d1bcba20ab22be64db916db2692e0a667f2671 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:50:57 -0800 Subject: [PATCH 04/11] refactor(config): extract magic numbers to constants - Create constants.py with timing and OAuth config - Update all action files to use constants: - VIEWER_UPDATE_INTERVAL_SECONDS - AD_SCHEDULE/DISPLAY_UPDATE_INTERVAL_SECONDS - CHAT_MODE_UPDATE_INTERVAL_SECONDS - ERROR_DISPLAY_DURATION_SECONDS - Update twitch_backend.py to use OAuth constants - Centralize configuration for easier modification --- actions/AdSchedule.py | 20 ++++++++++++++------ actions/ChatMode.py | 11 ++++++++--- actions/Clip.py | 4 +++- actions/Marker.py | 4 +++- actions/PlayAd.py | 4 +++- actions/SendMessage.py | 4 +++- actions/ShowViewers.py | 7 ++++--- twitch_backend.py | 10 ++++++---- 8 files changed, 44 insertions(+), 20 deletions(-) diff --git a/actions/AdSchedule.py b/actions/AdSchedule.py index 5900421..2f214d7 100644 --- a/actions/AdSchedule.py +++ b/actions/AdSchedule.py @@ -12,6 +12,12 @@ from loguru import logger as log +from constants import ( + AD_SCHEDULE_FETCH_INTERVAL_SECONDS, + AD_DISPLAY_UPDATE_INTERVAL_SECONDS, + ERROR_DISPLAY_DURATION_SECONDS, +) + class Icons(StrEnum): DELAY = "delay" @@ -74,7 +80,7 @@ def _update(): def _update_ad_display(self): """Consolidated update loop that fetches ad schedule and updates display.""" last_fetch_time = datetime.now() - timedelta( - seconds=30 + seconds=AD_SCHEDULE_FETCH_INTERVAL_SECONDS ) # Fetch immediately on start while self.get_is_present(): @@ -82,7 +88,9 @@ def _update_ad_display(self): now = datetime.now() # Fetch ad schedule every 30 seconds - if (now - last_fetch_time).total_seconds() >= 30: + if ( + now - last_fetch_time + ).total_seconds() >= AD_SCHEDULE_FETCH_INTERVAL_SECONDS: try: schedule, snoozes = self.backend.get_next_ad() self._next_ad = schedule @@ -90,7 +98,7 @@ def _update_ad_display(self): last_fetch_time = now except Exception as ex: log.error(f"Failed to get ad schedule from Twitch API: {ex}") - self.show_error(3) + self.show_error(ERROR_DISPLAY_DURATION_SECONDS) # Update display every second snooze_label = ( @@ -104,7 +112,7 @@ def _update_ad_display(self): if self._next_ad < now: self._update_background_color(Colors.DEFAULT) GLib.idle_add(lambda: self.set_center_label("")) - sleep(1) + sleep(AD_DISPLAY_UPDATE_INTERVAL_SECONDS) continue diff = (self._next_ad - now).total_seconds() time_label = self._convert_seconds_to_hh_mm_ss(diff) @@ -123,7 +131,7 @@ def _update_ad_display(self): except Exception as ex: log.error(f"Failed to update ad timer display: {ex}") - sleep(1) + sleep(AD_DISPLAY_UPDATE_INTERVAL_SECONDS) def _convert_seconds_to_hh_mm_ss(self, seconds) -> str: hours = seconds // 3600 @@ -138,4 +146,4 @@ def _on_snooze_ad(self, _): self.backend.snooze_ad() except Exception as ex: log.error(f"Failed to snooze next ad: {ex}") - self.show_error(3) + self.show_error(ERROR_DISPLAY_DURATION_SECONDS) diff --git a/actions/ChatMode.py b/actions/ChatMode.py index fb0a4c3..4980927 100644 --- a/actions/ChatMode.py +++ b/actions/ChatMode.py @@ -11,6 +11,11 @@ from loguru import logger as log +from constants import ( + CHAT_MODE_UPDATE_INTERVAL_SECONDS, + ERROR_DISPLAY_DURATION_SECONDS, +) + class Icons(StrEnum): FOLLOWER = "follower_mode" @@ -89,8 +94,8 @@ def _update_chat_mode(self): log.error( f"Failed to update chat mode status{f' for {mode}' if mode else ''}: {ex}" ) - self.show_error(3) - sleep(5) + self.show_error(ERROR_DISPLAY_DURATION_SECONDS) + sleep(CHAT_MODE_UPDATE_INTERVAL_SECONDS) def _on_toggle_chat(self, _): item = self._chat_select_row.get_selected_item().get_value() @@ -99,4 +104,4 @@ def _on_toggle_chat(self, _): self._update_icon(item, resp) except Exception as ex: log.error(f"Failed to toggle chat mode '{item}': {ex}") - self.show_error(3) + self.show_error(ERROR_DISPLAY_DURATION_SECONDS) diff --git a/actions/Clip.py b/actions/Clip.py index 7949d5d..680d608 100644 --- a/actions/Clip.py +++ b/actions/Clip.py @@ -6,6 +6,8 @@ from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input +from constants import ERROR_DISPLAY_DURATION_SECONDS + class Icons(StrEnum): CLIP = "camera" @@ -34,4 +36,4 @@ def _on_clip(self, _): self.backend.create_clip() except Exception as ex: log.error(f"Failed to create clip: {ex}") - self.show_error(3) + self.show_error(ERROR_DISPLAY_DURATION_SECONDS) diff --git a/actions/Marker.py b/actions/Marker.py index e8cb012..d8ffd3c 100644 --- a/actions/Marker.py +++ b/actions/Marker.py @@ -6,6 +6,8 @@ from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input +from constants import ERROR_DISPLAY_DURATION_SECONDS + class Icons(StrEnum): MARKER = "bookmark" @@ -34,4 +36,4 @@ def _on_marker(self, _): self.backend.create_marker() except Exception as ex: log.error(f"Failed to create stream marker: {ex}") - self.show_error(3) + self.show_error(ERROR_DISPLAY_DURATION_SECONDS) diff --git a/actions/PlayAd.py b/actions/PlayAd.py index d3a1cd4..8bbe4df 100644 --- a/actions/PlayAd.py +++ b/actions/PlayAd.py @@ -8,6 +8,8 @@ from GtkHelper.GenerativeUI.ComboRow import ComboRow from GtkHelper.ComboRow import SimpleComboRowItem, BaseComboRowItem +from constants import ERROR_DISPLAY_DURATION_SECONDS + class Icons(StrEnum): AD = "money" @@ -56,4 +58,4 @@ def _on_play_ad(self, _): log.error( f"Failed to play ad{f' (duration: {time}s)' if time else ''}: {ex}" ) - self.show_error(3) + self.show_error(ERROR_DISPLAY_DURATION_SECONDS) diff --git a/actions/SendMessage.py b/actions/SendMessage.py index 7a74e62..1b0a22a 100644 --- a/actions/SendMessage.py +++ b/actions/SendMessage.py @@ -8,6 +8,8 @@ from loguru import logger as log +from constants import ERROR_DISPLAY_DURATION_SECONDS + class Icons(StrEnum): CHAT = "chat" @@ -59,4 +61,4 @@ def _on_chat(self, _): self.backend.send_message(message, channel) except Exception as ex: log.error(f"Failed to send chat message to channel '{channel}': {ex}") - self.show_error(3) + self.show_error(ERROR_DISPLAY_DURATION_SECONDS) diff --git a/actions/ShowViewers.py b/actions/ShowViewers.py index 7e85d49..d2da527 100644 --- a/actions/ShowViewers.py +++ b/actions/ShowViewers.py @@ -9,6 +9,8 @@ from loguru import logger as log +from constants import VIEWER_UPDATE_INTERVAL_SECONDS + class Icons(StrEnum): VIEWERS = "view" @@ -22,8 +24,7 @@ def __init__(self, *args, **kwargs): self.icon_name = Icons.VIEWERS def on_ready(self): - Thread(target=self._update_viewers, daemon=True, - name="update_viewers").start() + Thread(target=self._update_viewers, daemon=True, name="update_viewers").start() def _update_viewers(self): while self.get_is_present(): @@ -31,4 +32,4 @@ def _update_viewers(self): if not count: count = "-" GLib.idle_add(lambda c=count: self.set_center_label(str(c))) - sleep(10) + sleep(VIEWER_UPDATE_INTERVAL_SECONDS) diff --git a/twitch_backend.py b/twitch_backend.py index 4711ca8..8708ba7 100644 --- a/twitch_backend.py +++ b/twitch_backend.py @@ -10,6 +10,8 @@ from streamcontroller_plugin_tools import BackendBase +from constants import OAUTH_REDIRECT_URI, OAUTH_PORT + def make_handler(plugin_backend: "Backend"): class AuthHandler(BaseHTTPRequestHandler): @@ -163,7 +165,7 @@ def update_client_credentials(self, client_id: str, client_secret: str) -> None: self.client_secret = client_secret params = { "client_id": client_id, - "redirect_uri": "http://localhost:3000/auth", + "redirect_uri": OAUTH_REDIRECT_URI, "response_type": "code", "scope": "user:write:chat channel:manage:broadcast moderator:manage:chat_settings clips:edit channel:read:subscriptions channel:edit:commercial channel:manage:ads channel:read:ads", } @@ -179,9 +181,9 @@ def update_client_credentials(self, client_id: str, client_secret: str) -> None: # Create new server try: - self.httpd = HTTPServer(("localhost", 3000), make_handler(self)) + self.httpd = HTTPServer(("localhost", OAUTH_PORT), make_handler(self)) except Exception as ex: - log.error(f"Failed to create HTTP server on port 3000: {ex}") + log.error(f"Failed to create HTTP server on port {OAUTH_PORT}: {ex}") self.auth_failed("Failed to start local authentication server") return @@ -221,7 +223,7 @@ def auth_with_code( client_id=client_id, client_secret=client_secret, tokens_path=self.token_path, - redirect_uri="http://localhost:3000/auth", + redirect_uri=OAUTH_REDIRECT_URI, authorization_code=auth_code, ) users = self.twitch.get_users() From 60e92e9db486bd467b1b38312e54d4aa51be4386 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:54:34 -0800 Subject: [PATCH 05/11] feat(api): add rate limiting to Twitch API calls - Add rate limit: 100 calls per 60 seconds - Implement thread-safe RateLimiter (sliding window) - Apply rate limiting to all Twitch API methods - Rate limiter auto-waits when limit reached - Prevent hitting Twitch API throttling (800/min) --- constants.py | 6 ++ twitch_backend.py | 148 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 140 insertions(+), 14 deletions(-) diff --git a/constants.py b/constants.py index b17485e..77000cc 100644 --- a/constants.py +++ b/constants.py @@ -14,3 +14,9 @@ # OAuth/Authentication OAUTH_REDIRECT_URI = "http://localhost:3000/auth" OAUTH_PORT = 3000 + +# Rate Limiting +# Twitch API standard rate limit: 800 requests per minute +# Using conservative limit to avoid hitting the cap +RATE_LIMIT_CALLS = 100 +RATE_LIMIT_PERIOD = 60 # seconds diff --git a/twitch_backend.py b/twitch_backend.py index 8708ba7..ded3d3b 100644 --- a/twitch_backend.py +++ b/twitch_backend.py @@ -4,13 +4,67 @@ import threading import requests from datetime import datetime, timedelta +from collections import deque +from functools import wraps +from time import sleep from loguru import logger as log from twitchpy.client import Client from streamcontroller_plugin_tools import BackendBase -from constants import OAUTH_REDIRECT_URI, OAUTH_PORT +from constants import ( + OAUTH_REDIRECT_URI, + OAUTH_PORT, + RATE_LIMIT_CALLS, + RATE_LIMIT_PERIOD, +) + + +class RateLimiter: + """Thread-safe rate limiter using a sliding window algorithm.""" + + def __init__(self, max_calls: int, period: float): + self.max_calls = max_calls + self.period = period + self.calls = deque() + self.lock = threading.Lock() + + def __call__(self, func): + @wraps(func) + def wrapper(*args, **kwargs): + with self.lock: + now = datetime.now() + # Remove calls outside the time window + while ( + self.calls and (now - self.calls[0]).total_seconds() > self.period + ): + self.calls.popleft() + + # Check if we've hit the rate limit + if len(self.calls) >= self.max_calls: + # Calculate how long to wait + oldest_call = self.calls[0] + wait_time = self.period - (now - oldest_call).total_seconds() + if wait_time > 0: + log.warning( + f"Rate limit reached, waiting {wait_time:.2f} seconds" + ) + sleep(wait_time) + # Clean up old calls again after waiting + now = datetime.now() + while ( + self.calls + and (now - self.calls[0]).total_seconds() > self.period + ): + self.calls.popleft() + + # Record this call + self.calls.append(datetime.now()) + + return func(*args, **kwargs) + + return wrapper def make_handler(plugin_backend: "Backend"): @@ -61,6 +115,7 @@ def __init__(self): self.httpd_thread: threading.Thread = None self.auth_code: str = None self.cached_channels: dict = {} + self.rate_limiter = RateLimiter(RATE_LIMIT_CALLS, RATE_LIMIT_PERIOD) def set_token_path(self, path: str) -> None: self.token_path = path @@ -82,7 +137,11 @@ def get_channel_id(self, user_name: str) -> str | None: if user_name in self.cached_channels: return self.cached_channels[user_name] - users = self.twitch.get_users(None, [user_name]) + @self.rate_limiter + def _get_users(): + return self.twitch.get_users(None, [user_name]) + + users = _get_users() if users: channel_id = users[0].user_id self.cached_channels[user_name] = channel_id @@ -94,19 +153,34 @@ def create_clip(self) -> None: if not self.twitch: return self.validate_auth() - self.twitch.create_clip(self.user_id) + + @self.rate_limiter + def _create_clip(): + return self.twitch.create_clip(self.user_id) + + _create_clip() def create_marker(self) -> None: if not self.twitch: return self.validate_auth() - self.twitch.create_stream_marker(self.user_id) + + @self.rate_limiter + def _create_marker(): + return self.twitch.create_stream_marker(self.user_id) + + _create_marker() def get_viewers(self) -> str: if not self.twitch: return self.validate_auth() - streams = self.twitch.get_streams(first=1, user_id=self.user_id) + + @self.rate_limiter + def _get_streams(): + return self.twitch.get_streams(first=1, user_id=self.user_id) + + streams = _get_streams() if not streams: return "Not Live" return str(streams[0].viewer_count) @@ -115,16 +189,32 @@ def toggle_chat_mode(self, mode: str) -> bool: if not self.twitch: return self.validate_auth() - current = self.twitch.get_chat_settings(self.user_id, self.user_id) + + @self.rate_limiter + def _get_settings(): + return self.twitch.get_chat_settings(self.user_id, self.user_id) + + @self.rate_limiter + def _update_settings(updated_value): + return self.twitch.update_chat_settings( + self.user_id, self.user_id, **{mode: updated_value} + ) + + current = _get_settings() updated = not getattr(current, mode) - self.twitch.update_chat_settings(self.user_id, self.user_id, **{mode: updated}) + _update_settings(updated) return updated def get_chat_settings(self) -> dict: if not self.twitch: return {} self.validate_auth() - current = self.twitch.get_chat_settings(self.user_id, self.user_id) + + @self.rate_limiter + def _get_settings(): + return self.twitch.get_chat_settings(self.user_id, self.user_id) + + current = _get_settings() return { "subscriber_mode": current.subscriber_mode, "follower_mode": current.follower_mode, @@ -137,25 +227,45 @@ def send_message(self, message: str, user_name: str) -> None: return self.validate_auth() channel_id = self.get_channel_id(user_name) or self.user_id - self.twitch.send_chat_message(channel_id, self.user_id, message) + + @self.rate_limiter + def _send_message(): + return self.twitch.send_chat_message(channel_id, self.user_id, message) + + _send_message() def snooze_ad(self) -> None: if not self.twitch: return self.validate_auth() - self.twitch.snooze_next_ad(self.user_id) + + @self.rate_limiter + def _snooze_ad(): + return self.twitch.snooze_next_ad(self.user_id) + + _snooze_ad() def play_ad(self, length: int) -> None: if not self.twitch: return self.validate_auth() - self.twitch.start_commercial(self.user_id, length) + + @self.rate_limiter + def _start_commercial(): + return self.twitch.start_commercial(self.user_id, length) + + _start_commercial() def get_next_ad(self) -> tuple[datetime, int]: if not self.twitch: return datetime.now() - timedelta(minutes=1), -1 self.validate_auth() - schedule = self.twitch.get_ad_schedule(self.user_id) + + @self.rate_limiter + def _get_ad_schedule(): + return self.twitch.get_ad_schedule(self.user_id) + + schedule = _get_ad_schedule() return schedule.next_ad_at, schedule.snooze_count def update_client_credentials(self, client_id: str, client_secret: str) -> None: @@ -211,7 +321,12 @@ def new_code(self, auth_code: str) -> None: def validate_auth(self) -> None: try: - _ = self.twitch.get_streams(first=1, user_id=self.user_id) + + @self.rate_limiter + def _validate(): + return self.twitch.get_streams(first=1, user_id=self.user_id) + + _ = _validate() except Exception as ex: self.auth_with_code(self.client_id, self.client_secret, self.auth_code) @@ -226,7 +341,12 @@ def auth_with_code( redirect_uri=OAUTH_REDIRECT_URI, authorization_code=auth_code, ) - users = self.twitch.get_users() + + @self.rate_limiter + def _get_users(): + return self.twitch.get_users() + + users = _get_users() self.auth_code = auth_code self.user_id = users[0].user_id self.client_id = client_id From d017a2e4bba1bc441e4bcd75b9d0078d9f9be367 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 08:58:01 -0800 Subject: [PATCH 06/11] feat(types): add type hints to core plugin files - Add typing imports: Optional, Callable, Any - Update twitch_backend.py with complete type hints - Update main.py with complete type hints - Add return type annotations to all methods --- main.py | 29 +++++++++-------- twitch_backend.py | 82 +++++++++++++++++++++++++---------------------- 2 files changed, 59 insertions(+), 52 deletions(-) diff --git a/main.py b/main.py index 528654f..2a11b1a 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import os import globals as gl import json +from typing import Optional, Callable, Any from loguru import logger from gi.repository import Gtk @@ -28,7 +29,7 @@ def get_selector_icon(self) -> Gtk.Widget: _, rendered = self.asset_manager.icons.get_asset_values("main") return Gtk.Image.new_from_pixbuf(image2pixbuf(rendered)) - def _add_icons(self): + def _add_icons(self) -> None: self.add_icon("main", self.get_asset_path("glitch_flat_purple.png")) self.add_icon("chat", self.get_asset_path("chat.png")) self.add_icon("camera", self.get_asset_path("camera.png")) @@ -41,19 +42,22 @@ def _add_icons(self): self.add_icon("money", self.get_asset_path("money.png")) self.add_icon("delay", self.get_asset_path("delay.png")) - def _add_colors(self): + def _add_colors(self) -> None: self.add_color("default", [0, 0, 0, 0]) self.add_color("warning", [255, 244, 79, 255]) self.add_color("alert", [224, 102, 102, 255]) - def _register_actions(self): + def _register_actions(self) -> None: self.message_action_holder = ActionHolder( - plugin_base=self, action_base=SendMessage, action_id_suffix="SendMessage", action_name="Send Chat Message", + plugin_base=self, + action_base=SendMessage, + action_id_suffix="SendMessage", + action_name="Send Chat Message", action_support={ Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(self.message_action_holder) @@ -135,7 +139,7 @@ def _register_actions(self): ) self.add_action_holder(self.ad_schedule_action_holder) - def _setup_backend(self): + def _setup_backend(self) -> bool: backend_path = os.path.join(self.PATH, "twitch_backend.py") self.launch_backend( backend_path=backend_path, @@ -144,8 +148,7 @@ def _setup_backend(self): ) backend_ready = self.wait_for_backend(tries=5) if not backend_ready: - logger.error( - "Failed to initialize Twitch backend after 5 attempts") + logger.error("Failed to initialize Twitch backend after 5 attempts") settings = self.get_settings() client_id = settings.get("client_id", "") @@ -161,15 +164,15 @@ def _setup_backend(self): self.backend.auth_with_code(client_id, client_secret, auth_code) return True - def __init__(self): + def __init__(self) -> None: super().__init__(use_legacy_locale=False) self.has_plugin_settings = True self.lm = self.locale_manager self.lm.set_to_os_default() - self._settings_manager = PluginSettings(self) - self.auth_callback_fn: callable = None - self.backend_initialized = False + self._settings_manager: PluginSettings = PluginSettings(self) + self.auth_callback_fn: Optional[Callable[[bool, str], None]] = None + self.backend_initialized: bool = False self._add_icons() self._add_colors() @@ -214,5 +217,5 @@ def on_auth_callback(self, success: bool, message: str = "") -> None: if self.auth_callback_fn: self.auth_callback_fn(success, message) - def get_settings_area(self): + def get_settings_area(self) -> Any: return self._settings_manager.get_settings_area() diff --git a/twitch_backend.py b/twitch_backend.py index ded3d3b..bdf6b51 100644 --- a/twitch_backend.py +++ b/twitch_backend.py @@ -7,6 +7,8 @@ from collections import deque from functools import wraps from time import sleep +from typing import Callable, Any, Optional +from collections.abc import Sequence from loguru import logger as log from twitchpy.client import Client @@ -24,15 +26,15 @@ class RateLimiter: """Thread-safe rate limiter using a sliding window algorithm.""" - def __init__(self, max_calls: int, period: float): - self.max_calls = max_calls - self.period = period - self.calls = deque() - self.lock = threading.Lock() + def __init__(self, max_calls: int, period: float) -> None: + self.max_calls: int = max_calls + self.period: float = period + self.calls: deque[datetime] = deque() + self.lock: threading.Lock = threading.Lock() - def __call__(self, func): + def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Any: with self.lock: now = datetime.now() # Remove calls outside the time window @@ -67,9 +69,9 @@ def wrapper(*args, **kwargs): return wrapper -def make_handler(plugin_backend: "Backend"): +def make_handler(plugin_backend: "Backend") -> type[BaseHTTPRequestHandler]: class AuthHandler(BaseHTTPRequestHandler): - def do_GET(self): + def do_GET(self) -> None: if not self.path.startswith("/auth"): self.send_response(201) return @@ -104,23 +106,25 @@ def do_GET(self): class Backend(BackendBase): - def __init__(self): + def __init__(self) -> None: super().__init__() - self.twitch: Client = None - self.user_id: str = None - self.token_path: str = None - self.client_secret: str = None - self.client_id: str = None - self.httpd: HTTPServer = None - self.httpd_thread: threading.Thread = None - self.auth_code: str = None - self.cached_channels: dict = {} - self.rate_limiter = RateLimiter(RATE_LIMIT_CALLS, RATE_LIMIT_PERIOD) + self.twitch: Optional[Client] = None + self.user_id: Optional[str] = None + self.token_path: Optional[str] = None + self.client_secret: Optional[str] = None + self.client_id: Optional[str] = None + self.httpd: Optional[HTTPServer] = None + self.httpd_thread: Optional[threading.Thread] = None + self.auth_code: Optional[str] = None + self.cached_channels: dict[str, str] = {} + self.rate_limiter: RateLimiter = RateLimiter( + RATE_LIMIT_CALLS, RATE_LIMIT_PERIOD + ) def set_token_path(self, path: str) -> None: self.token_path = path - def on_disconnect(self, conn): + def on_disconnect(self, conn: Any) -> None: if self.httpd is not None: try: self.httpd.shutdown() @@ -131,14 +135,14 @@ def on_disconnect(self, conn): self.httpd_thread = None super().on_disconnect(conn) - def get_channel_id(self, user_name: str) -> str | None: + def get_channel_id(self, user_name: str) -> Optional[str]: if not user_name: - return + return None if user_name in self.cached_channels: return self.cached_channels[user_name] @self.rate_limiter - def _get_users(): + def _get_users() -> Sequence[Any]: return self.twitch.get_users(None, [user_name]) users = _get_users() @@ -155,7 +159,7 @@ def create_clip(self) -> None: self.validate_auth() @self.rate_limiter - def _create_clip(): + def _create_clip() -> Any: return self.twitch.create_clip(self.user_id) _create_clip() @@ -166,18 +170,18 @@ def create_marker(self) -> None: self.validate_auth() @self.rate_limiter - def _create_marker(): + def _create_marker() -> Any: return self.twitch.create_stream_marker(self.user_id) _create_marker() def get_viewers(self) -> str: if not self.twitch: - return + return "" self.validate_auth() @self.rate_limiter - def _get_streams(): + def _get_streams() -> Sequence[Any]: return self.twitch.get_streams(first=1, user_id=self.user_id) streams = _get_streams() @@ -187,15 +191,15 @@ def _get_streams(): def toggle_chat_mode(self, mode: str) -> bool: if not self.twitch: - return + return False self.validate_auth() @self.rate_limiter - def _get_settings(): + def _get_settings() -> Any: return self.twitch.get_chat_settings(self.user_id, self.user_id) @self.rate_limiter - def _update_settings(updated_value): + def _update_settings(updated_value: bool) -> Any: return self.twitch.update_chat_settings( self.user_id, self.user_id, **{mode: updated_value} ) @@ -205,13 +209,13 @@ def _update_settings(updated_value): _update_settings(updated) return updated - def get_chat_settings(self) -> dict: + def get_chat_settings(self) -> dict[str, bool]: if not self.twitch: return {} self.validate_auth() @self.rate_limiter - def _get_settings(): + def _get_settings() -> Any: return self.twitch.get_chat_settings(self.user_id, self.user_id) current = _get_settings() @@ -229,7 +233,7 @@ def send_message(self, message: str, user_name: str) -> None: channel_id = self.get_channel_id(user_name) or self.user_id @self.rate_limiter - def _send_message(): + def _send_message() -> Any: return self.twitch.send_chat_message(channel_id, self.user_id, message) _send_message() @@ -240,7 +244,7 @@ def snooze_ad(self) -> None: self.validate_auth() @self.rate_limiter - def _snooze_ad(): + def _snooze_ad() -> Any: return self.twitch.snooze_next_ad(self.user_id) _snooze_ad() @@ -251,7 +255,7 @@ def play_ad(self, length: int) -> None: self.validate_auth() @self.rate_limiter - def _start_commercial(): + def _start_commercial() -> Any: return self.twitch.start_commercial(self.user_id, length) _start_commercial() @@ -262,7 +266,7 @@ def get_next_ad(self) -> tuple[datetime, int]: self.validate_auth() @self.rate_limiter - def _get_ad_schedule(): + def _get_ad_schedule() -> Any: return self.twitch.get_ad_schedule(self.user_id) schedule = _get_ad_schedule() @@ -323,7 +327,7 @@ def validate_auth(self) -> None: try: @self.rate_limiter - def _validate(): + def _validate() -> Sequence[Any]: return self.twitch.get_streams(first=1, user_id=self.user_id) _ = _validate() @@ -343,7 +347,7 @@ def auth_with_code( ) @self.rate_limiter - def _get_users(): + def _get_users() -> Sequence[Any]: return self.twitch.get_users() users = _get_users() From ddc72f0e173f0931a6b5fffe63aed451418f5243 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:00:28 -0800 Subject: [PATCH 07/11] feat(types): add type hints to settings and TwitchCore Add type hints to settings.py and TwitchCore --- actions/TwitchCore.py | 61 ++++++++++++++++++++++++++++++++++++------- settings.py | 42 +++++++++++++---------------- 2 files changed, 70 insertions(+), 33 deletions(-) diff --git a/actions/TwitchCore.py b/actions/TwitchCore.py index 3f0bcd3..ae3bf4c 100644 --- a/actions/TwitchCore.py +++ b/actions/TwitchCore.py @@ -2,6 +2,7 @@ from src.backend.PluginManager.ActionCore import ActionCore from src.backend.DeckManagement.InputIdentifier import InputEvent, Input from src.backend.PluginManager.PluginSettings.Asset import Color, Icon +from typing import Optional, Any from gi.repository import Gtk, Adw import gi @@ -11,17 +12,17 @@ class TwitchCore(ActionCore): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) # Setup AssetManager values - self.icon_keys = [] - self.color_keys = [] - self.current_icon: Icon = None - self.current_color: Color = None - self.icon_name = "" - self.color_name = "" - self.backend: 'Backend' = self.plugin_base.backend + self.icon_keys: list[str] = [] + self.color_keys: list[str] = [] + self.current_icon: Optional[Icon] = None + self.current_color: Optional[Color] = None + self.icon_name: str = "" + self.color_name: str = "" + self.backend: Any = self.plugin_base.backend self.plugin_base.asset_manager.icons.add_listener(self._icon_changed) self.plugin_base.asset_manager.colors.add_listener(self._color_changed) @@ -30,11 +31,53 @@ def __init__(self, *args, **kwargs): self.create_generative_ui() self.create_event_assigners() - def on_ready(self): + def on_ready(self) -> None: super().on_ready() self.display_icon() self.display_color() + def create_generative_ui(self) -> None: + pass + + def create_event_assigners(self) -> None: + pass + + def display_icon(self) -> None: + if not self.current_icon: + return + _, rendered = self.current_icon.get_values() + if rendered: + self.set_media(image=rendered) + + async def _icon_changed(self, event: str, key: str, asset: Any) -> None: + if not key in self.icon_keys: + return + if key != self.icon_name: + return + self.current_icon = asset + self.icon_name = key + self.display_icon() + + def display_color(self) -> None: + if not self.current_color: + return + color = self.current_color.get_values() + try: + self.set_background_color(color) + except: + # Sometimes we try to call this too early, and it leads to + # console errors, but no real errors. Ignoring this for now + pass + + async def _color_changed(self, event: str, key: str, asset: Any) -> None: + if not key in self.color_keys: + return + if key != self.color_name: + return + self.current_color = asset + self.color_name = key + self.display_color() + def create_generative_ui(self): pass diff --git a/settings.py b/settings.py index 44592ac..5dbc6f7 100644 --- a/settings.py +++ b/settings.py @@ -1,5 +1,6 @@ from gi.repository import Gtk, Adw import gi +from typing import Any from loguru import logger @@ -18,20 +19,18 @@ class PluginSettings: _client_secret: Adw.PasswordEntryRow _auth_button: Gtk.Button - def __init__(self, plugin_base: PluginBase): - self._plugin_base = plugin_base + def __init__(self, plugin_base: PluginBase) -> None: + self._plugin_base: PluginBase = plugin_base def get_settings_area(self) -> Adw.PreferencesGroup: if not self._plugin_base.backend or not self._plugin_base.backend_initialized: self._status_label = Gtk.Label( - label=self._plugin_base.lm.get( - "actions.base.credentials.failed"), + label=self._plugin_base.lm.get("actions.base.credentials.failed"), css_classes=["twitch-controller-red"], ) elif not self._plugin_base.backend.is_authed(): self._status_label = Gtk.Label( - label=self._plugin_base.lm.get( - "actions.base.credentials.failed"), + label=self._plugin_base.lm.get("actions.base.credentials.failed"), css_classes=["twitch-controller-red"], ) else: @@ -53,8 +52,7 @@ def get_settings_area(self) -> Adw.PreferencesGroup: self._auth_button.set_margin_top(10) self._auth_button.set_margin_bottom(10) self._client_id.connect("notify::text", self._on_change_client_id) - self._client_secret.connect( - "notify::text", self._on_change_client_secret) + self._client_secret.connect("notify::text", self._on_change_client_secret) self._auth_button.connect("clicked", self._on_auth_clicked) gh_link_label = self._plugin_base.lm.get("actions.info.link.label") @@ -68,8 +66,7 @@ def get_settings_area(self) -> Adw.PreferencesGroup: self._enable_auth() pref_group = Adw.PreferencesGroup() - pref_group.set_title(self._plugin_base.lm.get( - "actions.base.credentials.title")) + pref_group.set_title(self._plugin_base.lm.get("actions.base.credentials.title")) pref_group.add(self._status_label) pref_group.add(self._client_id) pref_group.add(self._client_secret) @@ -77,34 +74,34 @@ def get_settings_area(self) -> Adw.PreferencesGroup: pref_group.add(gh_label) return pref_group - def _load_settings(self): + def _load_settings(self) -> None: settings = self._plugin_base.get_settings() client_id = settings.get(KEY_CLIENT_ID, "") client_secret = settings.get(KEY_CLIENT_SECRET, "") self._client_id.set_text(client_id) self._client_secret.set_text(client_secret) - def _update_status(self, message: str, is_error: bool): + def _update_status(self, message: str, is_error: bool) -> None: style = "twitch-controller-red" if is_error else "twitch-controller-green" self._status_label.set_text(message) self._status_label.set_css_classes([style]) - def _update_settings(self, key: str, value: str): + def _update_settings(self, key: str, value: str) -> None: settings = self._plugin_base.get_settings() settings[key] = value self._plugin_base.set_settings(settings) - def _on_change_client_id(self, entry, _): + def _on_change_client_id(self, entry: Any, _: Any) -> None: val = entry.get_text().strip() self._update_settings(KEY_CLIENT_ID, val) self._enable_auth() - def _on_change_client_secret(self, entry, _): + def _on_change_client_secret(self, entry: Any, _: Any) -> None: val = entry.get_text().strip() self._update_settings(KEY_CLIENT_SECRET, val) self._enable_auth() - def _on_auth_clicked(self, _): + def _on_auth_clicked(self, _: Any) -> None: if not self._plugin_base.backend: self._update_status("Failed to load backend", True) return @@ -112,20 +109,17 @@ def _on_auth_clicked(self, _): client_id = settings.get(KEY_CLIENT_ID) client_secret = settings.get(KEY_CLIENT_SECRET) self._plugin_base.auth_callback_fn = self._on_auth_completed - self._plugin_base.backend.update_client_credentials( - client_id, client_secret) + self._plugin_base.backend.update_client_credentials(client_id, client_secret) - def _enable_auth(self): + def _enable_auth(self) -> None: settings = self._plugin_base.get_settings() client_secret = settings.get(KEY_CLIENT_SECRET, "") client_id = settings.get(KEY_CLIENT_ID, "") - self._auth_button.set_sensitive( - len(client_id) > 0 and len(client_secret) > 0) + self._auth_button.set_sensitive(len(client_id) > 0 and len(client_secret) > 0) - def _on_auth_completed(self, success: bool, message: str = ""): + def _on_auth_completed(self, success: bool, message: str = "") -> None: self._enable_auth() if not message: lm_key = "authenticated" if success else "failed" - message = self._plugin_base.lm.get( - f"actions.base.credentials.{lm_key}") + message = self._plugin_base.lm.get(f"actions.base.credentials.{lm_key}") self._update_status(message, not success) From 7b91e9b555a7c301759f921fa086c2e10d6a8754 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:04:21 -0800 Subject: [PATCH 08/11] feat(types): add type hints to all action files - Add type hints to all 7 action files - Add null checks for Optional values - Import typing (Any, Optional, List, Union) --- actions/AdSchedule.py | 19 ++++++++++--------- actions/ChatMode.py | 26 ++++++++++++++------------ actions/Clip.py | 7 ++++--- actions/Marker.py | 7 ++++--- actions/PlayAd.py | 16 +++++++++------- actions/SendMessage.py | 11 ++++++----- actions/ShowViewers.py | 9 +++++---- 7 files changed, 52 insertions(+), 43 deletions(-) diff --git a/actions/AdSchedule.py b/actions/AdSchedule.py index 2f214d7..4681866 100644 --- a/actions/AdSchedule.py +++ b/actions/AdSchedule.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from threading import Thread from time import sleep +from typing import Any, List from gi.repository import GLib from GtkHelper.GenerativeUI.SwitchRow import SwitchRow @@ -30,7 +31,7 @@ class Colors(StrEnum): class AdSchedule(TwitchCore): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.icon_keys = [Icons.DELAY] self.current_icon = self.get_icon(Icons.DELAY) @@ -41,13 +42,13 @@ def __init__(self, *args, **kwargs): self._next_ad: datetime = datetime.now() self._snoozes: int = -1 - def on_ready(self): + def on_ready(self) -> None: super().on_ready() Thread( target=self._update_ad_display, daemon=True, name="update_ad_display" ).start() - def create_event_assigners(self): + def create_event_assigners(self) -> None: self.event_manager.add_event_assigner( EventAssigner( id="snooze-ad", @@ -57,7 +58,7 @@ def create_event_assigners(self): ) ) - def create_generative_ui(self): + def create_generative_ui(self) -> None: self._skip_ad_switch = SwitchRow( action_core=self, var_name="ad.snooze", @@ -67,17 +68,17 @@ def create_generative_ui(self): complex_var_name=True, ) - def get_config_rows(self): + def get_config_rows(self) -> List[Any]: return [self._skip_ad_switch.widget] - def _update_background_color(self, color: str): + def _update_background_color(self, color: str) -> None: def _update(): self.current_color = self.get_color(color) self.display_color() GLib.idle_add(_update) - def _update_ad_display(self): + def _update_ad_display(self) -> None: """Consolidated update loop that fetches ad schedule and updates display.""" last_fetch_time = datetime.now() - timedelta( seconds=AD_SCHEDULE_FETCH_INTERVAL_SECONDS @@ -133,13 +134,13 @@ def _update_ad_display(self): sleep(AD_DISPLAY_UPDATE_INTERVAL_SECONDS) - def _convert_seconds_to_hh_mm_ss(self, seconds) -> str: + def _convert_seconds_to_hh_mm_ss(self, seconds: float) -> str: hours = seconds // 3600 minutes = (seconds % 3600) // 60 remaining_seconds = seconds % 60 return f"{int(hours):02}:{int(minutes):02}:{int(remaining_seconds):02}" - def _on_snooze_ad(self, _): + def _on_snooze_ad(self, _: Any) -> None: if not self._skip_ad_switch.get_active(): return try: diff --git a/actions/ChatMode.py b/actions/ChatMode.py index 4980927..5a1b4dd 100644 --- a/actions/ChatMode.py +++ b/actions/ChatMode.py @@ -1,6 +1,7 @@ from enum import StrEnum, Enum from threading import Thread from time import sleep +from typing import Any, List, Optional from gi.repository import GLib from .TwitchCore import TwitchCore @@ -32,13 +33,13 @@ class ChatModeOptions(Enum): class ChatMode(TwitchCore): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.icon_keys = [Icons.FOLLOWER, Icons.SUBSCRIBER, Icons.EMOTE, Icons.SLOW] self.current_icon = self.get_icon(Icons.FOLLOWER) self.icon_name = Icons.FOLLOWER - def create_event_assigners(self): + def create_event_assigners(self) -> None: self.event_manager.add_event_assigner( EventAssigner( id="chat-toggle", @@ -48,7 +49,7 @@ def create_event_assigners(self): ) ) - def create_generative_ui(self): + def create_generative_ui(self) -> None: self._chat_select_row = ComboRow( action_core=self, var_name="chat.mode", @@ -64,32 +65,33 @@ def create_generative_ui(self): on_change=self._change_chat_mode, ) - def on_ready(self): + def on_ready(self) -> None: Thread( target=self._update_chat_mode, daemon=True, name="update_chat_mode" ).start() - def get_config_rows(self): + def get_config_rows(self) -> List[Any]: return [self._chat_select_row.widget] - def _change_chat_mode(self, _, new, __): + def _change_chat_mode(self, _: Any, new: str, __: Any) -> None: self.icon_name = Icons(new) self.current_icon = self.get_icon(self.icon_name) self.display_icon() - def _update_icon(self, mode: str, enabled: bool): + def _update_icon(self, mode: str, enabled: bool) -> None: # TODO: Custom icons for enabled/disabled label = "Enabled" if enabled else "Disabled" GLib.idle_add(lambda: self.set_center_label(label)) - def _update_chat_mode(self): + def _update_chat_mode(self) -> None: while self.get_is_present(): - mode = None + mode: Optional[str] = None try: chat_settings = self.backend.get_chat_settings() mode = self._chat_select_row.get_selected_item().get_value() - enabled = chat_settings.get(mode) - self._update_icon(mode, enabled) + if mode: + enabled = chat_settings.get(mode) + self._update_icon(mode, enabled) except Exception as ex: log.error( f"Failed to update chat mode status{f' for {mode}' if mode else ''}: {ex}" @@ -97,7 +99,7 @@ def _update_chat_mode(self): self.show_error(ERROR_DISPLAY_DURATION_SECONDS) sleep(CHAT_MODE_UPDATE_INTERVAL_SECONDS) - def _on_toggle_chat(self, _): + def _on_toggle_chat(self, _: Any) -> None: item = self._chat_select_row.get_selected_item().get_value() try: resp = self.backend.toggle_chat_mode(item) diff --git a/actions/Clip.py b/actions/Clip.py index 680d608..e1e9868 100644 --- a/actions/Clip.py +++ b/actions/Clip.py @@ -1,4 +1,5 @@ from enum import StrEnum +from typing import Any from loguru import logger as log @@ -14,14 +15,14 @@ class Icons(StrEnum): class Clip(TwitchCore): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.icon_keys = [Icons.CLIP] self.current_icon = self.get_icon(Icons.CLIP) self.icon_name = Icons.CLIP self.has_configuration = False - def create_event_assigners(self): + def create_event_assigners(self) -> None: self.event_manager.add_event_assigner( EventAssigner( id="clip", @@ -31,7 +32,7 @@ def create_event_assigners(self): ) ) - def _on_clip(self, _): + def _on_clip(self, _: Any) -> None: try: self.backend.create_clip() except Exception as ex: diff --git a/actions/Marker.py b/actions/Marker.py index d8ffd3c..922a8e8 100644 --- a/actions/Marker.py +++ b/actions/Marker.py @@ -1,4 +1,5 @@ from enum import StrEnum +from typing import Any from loguru import logger as log @@ -14,14 +15,14 @@ class Icons(StrEnum): class Marker(TwitchCore): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.icon_keys = [Icons.MARKER] self.current_icon = self.get_icon(Icons.MARKER) self.icon_name = Icons.MARKER self.has_configuration = False - def create_event_assigners(self): + def create_event_assigners(self) -> None: self.event_manager.add_event_assigner( EventAssigner( id="marker", @@ -31,7 +32,7 @@ def create_event_assigners(self): ) ) - def _on_marker(self, _): + def _on_marker(self, _: Any) -> None: try: self.backend.create_marker() except Exception as ex: diff --git a/actions/PlayAd.py b/actions/PlayAd.py index 8bbe4df..11805b0 100644 --- a/actions/PlayAd.py +++ b/actions/PlayAd.py @@ -1,4 +1,5 @@ from enum import StrEnum, Enum +from typing import Any, List, Optional from loguru import logger as log @@ -16,14 +17,14 @@ class Icons(StrEnum): class PlayAd(TwitchCore): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.icon_keys = [Icons.AD] self.current_icon = self.get_icon(Icons.AD) self.icon_name = Icons.AD self.has_configuration = True - def create_generative_ui(self): + def create_generative_ui(self) -> None: options = [ SimpleComboRowItem(str(x), f"{x} seconds") for x in [30, 60, 90, 120] ] @@ -36,10 +37,10 @@ def create_generative_ui(self): complex_var_name=True, ) - def get_config_rows(self): + def get_config_rows(self) -> List[Any]: return [self._time_row.widget] - def create_event_assigners(self): + def create_event_assigners(self) -> None: self.event_manager.add_event_assigner( EventAssigner( id="play-ad", @@ -49,11 +50,12 @@ def create_event_assigners(self): ) ) - def _on_play_ad(self, _): - time = None + def _on_play_ad(self, _: Any) -> None: + time: Optional[str] = None try: time = self._time_row.get_selected_item().get_value() - self.backend.play_ad(int(time)) + if time: + self.backend.play_ad(int(time)) except Exception as ex: log.error( f"Failed to play ad{f' (duration: {time}s)' if time else ''}: {ex}" diff --git a/actions/SendMessage.py b/actions/SendMessage.py index 1b0a22a..102b73f 100644 --- a/actions/SendMessage.py +++ b/actions/SendMessage.py @@ -1,4 +1,5 @@ from enum import StrEnum +from typing import Any, List from .TwitchCore import TwitchCore from src.backend.PluginManager.EventAssigner import EventAssigner @@ -16,14 +17,14 @@ class Icons(StrEnum): class SendMessage(TwitchCore): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.icon_keys = [Icons.CHAT] self.current_icon = self.get_icon(Icons.CHAT) self.icon_name = Icons.CHAT self.has_configuration = True - def create_event_assigners(self): + def create_event_assigners(self) -> None: self.event_manager.add_event_assigner( EventAssigner( id="chat", @@ -33,7 +34,7 @@ def create_event_assigners(self): ) ) - def create_generative_ui(self): + def create_generative_ui(self) -> None: self.message_row = EntryRow( action_core=self, var_name="chat.message_text", @@ -51,10 +52,10 @@ def create_generative_ui(self): complex_var_name=True, ) - def get_config_rows(self): + def get_config_rows(self) -> List[Any]: return [self.message_row.widget, self.channel_row.widget] - def _on_chat(self, _): + def _on_chat(self, _: Any) -> None: message = self.message_row.get_value() channel = self.channel_row.get_value() try: diff --git a/actions/ShowViewers.py b/actions/ShowViewers.py index d2da527..52b5ea9 100644 --- a/actions/ShowViewers.py +++ b/actions/ShowViewers.py @@ -1,6 +1,7 @@ from enum import StrEnum from threading import Thread from time import sleep +from typing import Optional, Union from gi.repository import GLib from .TwitchCore import TwitchCore @@ -17,18 +18,18 @@ class Icons(StrEnum): class ShowViewers(TwitchCore): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.icon_keys = [Icons.VIEWERS] self.current_icon = self.get_icon(Icons.VIEWERS) self.icon_name = Icons.VIEWERS - def on_ready(self): + def on_ready(self) -> None: Thread(target=self._update_viewers, daemon=True, name="update_viewers").start() - def _update_viewers(self): + def _update_viewers(self) -> None: while self.get_is_present(): - count = self.backend.get_viewers() + count: Union[Optional[int], str] = self.backend.get_viewers() if not count: count = "-" GLib.idle_add(lambda c=count: self.set_center_label(str(c))) From 9b2f099ea5c421f16848514cf72971e85e3f3577 Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:05:35 -0800 Subject: [PATCH 09/11] chore(docs): add docstrings to key classes and methods - Add RateLimiter docstring explaining algorithm - Add Backend class docstring - Add docstrings to key Backend methods - Add PluginTemplate docstring --- main.py | 12 ++++++++++++ twitch_backend.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 2a11b1a..169c6f1 100644 --- a/main.py +++ b/main.py @@ -25,6 +25,18 @@ class PluginTemplate(PluginBase): + """Twitch StreamController Plugin. + + Provides integration with Twitch for StreamController, enabling actions like: + - Creating clips and stream markers + - Sending chat messages + - Displaying viewer counts + - Managing chat modes (follower-only, emote-only, slow mode, etc.) + - Playing ads and managing ad schedules + + All Twitch API calls are rate-limited to prevent exceeding API limits. + """ + def get_selector_icon(self) -> Gtk.Widget: _, rendered = self.asset_manager.icons.get_asset_values("main") return Gtk.Image.new_from_pixbuf(image2pixbuf(rendered)) diff --git a/twitch_backend.py b/twitch_backend.py index bdf6b51..cabd74c 100644 --- a/twitch_backend.py +++ b/twitch_backend.py @@ -24,7 +24,22 @@ class RateLimiter: - """Thread-safe rate limiter using a sliding window algorithm.""" + """Thread-safe rate limiter using a sliding window algorithm. + + This decorator limits the number of function calls within a specified time period. + It uses a sliding window approach where old calls outside the time window are + automatically removed, and new calls are blocked if the limit is reached. + + Args: + max_calls: Maximum number of calls allowed within the time period + period: Time period in seconds for the rate limit window + + Example: + @RateLimiter(max_calls=100, period=60) + def api_call(): + # This function will be limited to 100 calls per 60 seconds + pass + """ def __init__(self, max_calls: int, period: float) -> None: self.max_calls: int = max_calls @@ -106,6 +121,13 @@ def do_GET(self) -> None: class Backend(BackendBase): + """Backend for Twitch API integration. + + Handles authentication, API calls, and rate limiting for Twitch operations. + All API methods are automatically rate-limited to prevent exceeding Twitch's + API limits. + """ + def __init__(self) -> None: super().__init__() self.twitch: Optional[Client] = None @@ -136,6 +158,14 @@ def on_disconnect(self, conn: Any) -> None: super().on_disconnect(conn) def get_channel_id(self, user_name: str) -> Optional[str]: + """Get Twitch channel ID from username. + + Args: + user_name: Twitch username to look up + + Returns: + Channel ID if found, None otherwise. Results are cached for performance. + """ if not user_name: return None if user_name in self.cached_channels: @@ -154,6 +184,7 @@ def _get_users() -> Sequence[Any]: return None def create_clip(self) -> None: + """Create a clip of the current live stream.""" if not self.twitch: return self.validate_auth() From f121c4dfbc7cb6508c8fc5d031785af499cfcb4c Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:25:11 -0800 Subject: [PATCH 10/11] fix(constants): use proper package pathing --- actions/AdSchedule.py | 2 +- actions/ChatMode.py | 2 +- actions/Clip.py | 2 +- actions/Marker.py | 2 +- actions/PlayAd.py | 2 +- actions/SendMessage.py | 2 +- actions/ShowViewers.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/actions/AdSchedule.py b/actions/AdSchedule.py index 4681866..ce92c68 100644 --- a/actions/AdSchedule.py +++ b/actions/AdSchedule.py @@ -13,7 +13,7 @@ from loguru import logger as log -from constants import ( +from ..constants import ( AD_SCHEDULE_FETCH_INTERVAL_SECONDS, AD_DISPLAY_UPDATE_INTERVAL_SECONDS, ERROR_DISPLAY_DURATION_SECONDS, diff --git a/actions/ChatMode.py b/actions/ChatMode.py index 5a1b4dd..8fe31fd 100644 --- a/actions/ChatMode.py +++ b/actions/ChatMode.py @@ -12,7 +12,7 @@ from loguru import logger as log -from constants import ( +from ..constants import ( CHAT_MODE_UPDATE_INTERVAL_SECONDS, ERROR_DISPLAY_DURATION_SECONDS, ) diff --git a/actions/Clip.py b/actions/Clip.py index e1e9868..09cd87b 100644 --- a/actions/Clip.py +++ b/actions/Clip.py @@ -7,7 +7,7 @@ from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input -from constants import ERROR_DISPLAY_DURATION_SECONDS +from ..constants import ERROR_DISPLAY_DURATION_SECONDS class Icons(StrEnum): diff --git a/actions/Marker.py b/actions/Marker.py index 922a8e8..e05a3a6 100644 --- a/actions/Marker.py +++ b/actions/Marker.py @@ -7,7 +7,7 @@ from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input -from constants import ERROR_DISPLAY_DURATION_SECONDS +from ..constants import ERROR_DISPLAY_DURATION_SECONDS class Icons(StrEnum): diff --git a/actions/PlayAd.py b/actions/PlayAd.py index 11805b0..df373a9 100644 --- a/actions/PlayAd.py +++ b/actions/PlayAd.py @@ -9,7 +9,7 @@ from GtkHelper.GenerativeUI.ComboRow import ComboRow from GtkHelper.ComboRow import SimpleComboRowItem, BaseComboRowItem -from constants import ERROR_DISPLAY_DURATION_SECONDS +from ..constants import ERROR_DISPLAY_DURATION_SECONDS class Icons(StrEnum): diff --git a/actions/SendMessage.py b/actions/SendMessage.py index 102b73f..5787baa 100644 --- a/actions/SendMessage.py +++ b/actions/SendMessage.py @@ -9,7 +9,7 @@ from loguru import logger as log -from constants import ERROR_DISPLAY_DURATION_SECONDS +from ..constants import ERROR_DISPLAY_DURATION_SECONDS class Icons(StrEnum): diff --git a/actions/ShowViewers.py b/actions/ShowViewers.py index 52b5ea9..9d15a56 100644 --- a/actions/ShowViewers.py +++ b/actions/ShowViewers.py @@ -10,7 +10,7 @@ from loguru import logger as log -from constants import VIEWER_UPDATE_INTERVAL_SECONDS +from ..constants import VIEWER_UPDATE_INTERVAL_SECONDS class Icons(StrEnum): From 9f82ce237fdc761c89b94382098a0541dc0c936f Mon Sep 17 00:00:00 2001 From: Devin Collins <3997333+ImDevinC@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:34:25 -0800 Subject: [PATCH 11/11] chore(manifest): Update manifest --- manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index e332618..fe10a32 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "version": "1.6.0", + "version": "1.7.0", "thumbnail": "store/thumbnail.png", "id": "com_imdevinc_StreamControllerTwitchPlugin", "name": "Twitch Integration",