From 95aa885cb248708512af34aa2193c68ea393d879 Mon Sep 17 00:00:00 2001 From: Finalspace Date: Tue, 9 Jun 2026 16:15:39 +0200 Subject: [PATCH] Fix OBS icons stuck on error after OBS restart When OBS is closed and reopened, the action icons stayed on the error triangle and never recovered. Three combined defects: 1. backend.py: an empty websocket response (OBS closed) caused KeyError on status.datain[...] which crashed the action tick. Add build_status_dict() which returns None on a missing/empty response and marks the connection as lost (no crash). 2. OBSController.py: the obs-websocket "connected" flag was not a reliable disconnect signal (on_disconnect did not always fire), so reconnection never triggered. Disable the library's competing auto-reconnect (authreconnect=0) and add teardown_existing_connection() for a clean reconnect. 3. OBSActionBase.py + actions: drive a throttled reconnect from the tick loop (try_reconnect_if_disconnected), and call hide_error() in each status success path so the persistent error overlay is cleared once OBS is reachable again (only ToggleStream did this before, which is why recording/others stayed stuck). Co-Authored-By: Claude Opus 4.8 --- OBSActionBase.py | 29 +++++ actions/Filter/FilterBase.py | 1 + actions/InputDial/InputDial.py | 4 + actions/InputMute/InputMuteBase.py | 3 + actions/RecPlayPause/RecPlayPause.py | 1 + actions/SceneItem/SceneItem.py | 3 + actions/ToggleRecord/ToggleRecord.py | 4 + .../ToggleReplayBuffer/ToggleReplayBuffer.py | 3 + actions/ToggleStream/ToggleStream.py | 1 + actions/ToggleStudioMode/ToggleStudioMode.py | 3 + .../ToggleVirtualCamera.py | 3 + backend/OBSController.py | 32 +++++- backend/backend.py | 100 +++++++++--------- 13 files changed, 134 insertions(+), 53 deletions(-) diff --git a/OBSActionBase.py b/OBSActionBase.py index a2de89a..0df7983 100644 --- a/OBSActionBase.py +++ b/OBSActionBase.py @@ -11,15 +11,24 @@ from gi.repository import Gtk, Adw import threading +import time from loguru import logger as log +# When OBS is not reachable we retry the connection from the tick loop so the icon recovers +# automatically once OBS is started again. Throttle the attempts so that a tick firing every +# second does not pile up a new (3 second timeout) connection attempt on top of the previous one. +RECONNECT_THROTTLE_SECONDS = 5.0 + + class OBSActionBase(ActionBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.has_configuration = True + self.last_reconnect_attempt = 0.0 + self.status_label = Gtk.Label( label=self.plugin_base.lm.get("actions.base.status.no-connection"), css_classes=["bold", "red"] ) @@ -84,6 +93,26 @@ def on_change_password(self, entry, *args): self.reconnect_obs() + def try_reconnect_if_disconnected(self): + """Trigger a throttled background reconnect when OBS is not connected. + + Called from the tick loop so that the action recovers on its own once OBS is reachable + again instead of being stuck on the error icon until the page is reloaded. + """ + backend = self.plugin_base.backend + if backend is None: + return + if backend.get_connected(): + return + + now = time.time() + seconds_since_last_attempt = now - self.last_reconnect_attempt + if seconds_since_last_attempt < RECONNECT_THROTTLE_SECONDS: + return + + self.last_reconnect_attempt = now + self.reconnect_obs() + def reconnect_obs(self): threading.Thread(target=self._reconnect_obs, daemon=True, name="reconnect_obs").start() diff --git a/actions/Filter/FilterBase.py b/actions/Filter/FilterBase.py index 9933c4b..c8e4b0f 100644 --- a/actions/Filter/FilterBase.py +++ b/actions/Filter/FilterBase.py @@ -195,6 +195,7 @@ def on_key_down(self): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_filter_status() def reconnect_obs(self): diff --git a/actions/InputDial/InputDial.py b/actions/InputDial/InputDial.py index 82ae407..97974fb 100644 --- a/actions/InputDial/InputDial.py +++ b/actions/InputDial/InputDial.py @@ -69,6 +69,9 @@ def show_current_input_volume(self): return self.volume = self.db_to_volume(status["volume"]) + # Connection is healthy again: clear any error overlay left by a previous show_error(). + self.hide_error() + # Now render the button image = "input_muted.png" if self.muted else "input_unmuted.png" label = f"{self.volume}%" @@ -196,6 +199,7 @@ def volume_change(self, diff): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_input_volume() def reconnect_obs(self): diff --git a/actions/InputMute/InputMuteBase.py b/actions/InputMute/InputMuteBase.py index 3ff1074..e6ae229 100644 --- a/actions/InputMute/InputMuteBase.py +++ b/actions/InputMute/InputMuteBase.py @@ -76,6 +76,8 @@ def show_current_input_mute_status(self): self.show_error() self.set_media() return + # Connection is healthy again: clear any error overlay left by a previous show_error(). + self.hide_error() if status["muted"]: self.show_for_state(State.DISABLED) else: @@ -178,6 +180,7 @@ def on_key_down(self): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_input_mute_status() def reconnect_obs(self): diff --git a/actions/RecPlayPause/RecPlayPause.py b/actions/RecPlayPause/RecPlayPause.py index 4073a27..2bef165 100644 --- a/actions/RecPlayPause/RecPlayPause.py +++ b/actions/RecPlayPause/RecPlayPause.py @@ -65,4 +65,5 @@ def on_key_down(self): self.plugin_base.backend.toggle_record_pause() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_rec_status() \ No newline at end of file diff --git a/actions/SceneItem/SceneItem.py b/actions/SceneItem/SceneItem.py index 6ec4e60..aa5d3bf 100644 --- a/actions/SceneItem/SceneItem.py +++ b/actions/SceneItem/SceneItem.py @@ -59,6 +59,8 @@ def show_current_scene_item_status(self): self.show_error() self.set_media() return + # Connection is healthy again: clear any error overlay left by a previous show_error(). + self.hide_error() if status["enabled"]: self.show_for_state(State.ENABLED) else: @@ -198,6 +200,7 @@ def on_key_down(self): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_scene_item_status() def reconnect_obs(self): diff --git a/actions/ToggleRecord/ToggleRecord.py b/actions/ToggleRecord/ToggleRecord.py index 865d7ec..ed64bbe 100644 --- a/actions/ToggleRecord/ToggleRecord.py +++ b/actions/ToggleRecord/ToggleRecord.py @@ -35,6 +35,9 @@ def show_current_rec_status(self, new_paused = False): self.current_state = -1 self.show_error() return + # The connection is healthy again, so clear any error overlay set by a previous show_error(). + # Without this the error triangle stays on top of the normal icon even after recovery. + self.hide_error() if status["paused"]: self.show_for_state(2) elif status["active"]: @@ -80,6 +83,7 @@ def on_key_down(self): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_rec_status() def show_rec_time(self): diff --git a/actions/ToggleReplayBuffer/ToggleReplayBuffer.py b/actions/ToggleReplayBuffer/ToggleReplayBuffer.py index a5550d3..39ef183 100644 --- a/actions/ToggleReplayBuffer/ToggleReplayBuffer.py +++ b/actions/ToggleReplayBuffer/ToggleReplayBuffer.py @@ -38,6 +38,8 @@ def show_current_replay_buffer_status(self, new_paused = False): self.show_error() self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) return + # Connection is healthy again: clear any error overlay left by a previous show_error(). + self.hide_error() if status["active"]: self.show_for_state(1) else: @@ -78,4 +80,5 @@ def on_key_down(self): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_replay_buffer_status() \ No newline at end of file diff --git a/actions/ToggleStream/ToggleStream.py b/actions/ToggleStream/ToggleStream.py index 897391d..d0cbc22 100644 --- a/actions/ToggleStream/ToggleStream.py +++ b/actions/ToggleStream/ToggleStream.py @@ -78,6 +78,7 @@ def on_key_down(self): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_stream_status() def show_stream_time(self): diff --git a/actions/ToggleStudioMode/ToggleStudioMode.py b/actions/ToggleStudioMode/ToggleStudioMode.py index 117c210..6f4058f 100644 --- a/actions/ToggleStudioMode/ToggleStudioMode.py +++ b/actions/ToggleStudioMode/ToggleStudioMode.py @@ -38,6 +38,8 @@ def show_current_studio_mode_status(self, new_paused = False): self.show_error() self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) return + # Connection is healthy again: clear any error overlay left by a previous show_error(). + self.hide_error() if status["active"]: self.show_for_state(1) else: @@ -78,4 +80,5 @@ def on_key_down(self): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_studio_mode_status() \ No newline at end of file diff --git a/actions/ToggleVirtualCamera/ToggleVirtualCamera.py b/actions/ToggleVirtualCamera/ToggleVirtualCamera.py index 400e11f..a6b2c37 100644 --- a/actions/ToggleVirtualCamera/ToggleVirtualCamera.py +++ b/actions/ToggleVirtualCamera/ToggleVirtualCamera.py @@ -38,6 +38,8 @@ def show_current_virtual_camera_status(self, new_paused = False): self.show_error() self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) return + # Connection is healthy again: clear any error overlay left by a previous show_error(). + self.hide_error() if status["active"]: self.show_for_state(1) else: @@ -78,4 +80,5 @@ def on_key_down(self): self.on_tick() def on_tick(self): + self.try_reconnect_if_disconnected() self.show_current_virtual_camera_status() \ No newline at end of file diff --git a/backend/OBSController.py b/backend/OBSController.py index 73b5d18..795949b 100644 --- a/backend/OBSController.py +++ b/backend/OBSController.py @@ -5,6 +5,11 @@ import socket import ipaddress +# We drive reconnection ourselves from the action tick loop (see OBSActionBase). The websocket +# library's own auto-reconnect is therefore disabled, otherwise its background reconnect thread +# competes with ours and can leave the connection state inconsistent (stuck on the error icon). +LIBRARY_AUTORECONNECT_DISABLED = 0 + class OBSController(obsws): def __init__(self): self.connected = False @@ -48,6 +53,24 @@ def register(self, *args, **kwargs): except (obswebsocket.exceptions.MessageTimeout, websocket._exceptions.WebSocketConnectionClosedException, KeyError) as e: log.error(e) + def teardown_existing_connection(self): + """Cleanly stop a previous connection's receive thread and socket before reconnecting. + + Without this a stale receive thread can keep running against the old (dead) socket after + we build a new connection, which leaves the connection state unreliable. + """ + existing_recv_thread = getattr(self, "thread_recv", None) + if existing_recv_thread is not None: + existing_recv_thread.running = False + + existing_ws = getattr(self, "ws", None) + if existing_ws is not None: + try: + if existing_ws.connected: + existing_ws.close() + except Exception as e: + log.error(e) + def connect_to(self, host=None, port=None, timeout=1, legacy=False, **kwargs): if not self.validate_ip(host): log.error("Invalid IP address for OBS connection") @@ -60,16 +83,17 @@ def connect_to(self, host=None, port=None, timeout=1, legacy=False, **kwargs): try: log.debug(f"Trying to connect to obs with legacy: {legacy}") - super().__init__(host=host, port=port, timeout=timeout, legacy=legacy, on_connect=self.on_connect, on_disconnect=self.on_disconnect, authreconnect=5, **kwargs) - self.event_obs = obsws(host=host, port=port, timeout=timeout, legacy=legacy, on_connect=self.on_connect, on_disconnect=self.on_disconnect, authreconnect=5, **kwargs) + self.teardown_existing_connection() + super().__init__(host=host, port=port, timeout=timeout, legacy=legacy, on_connect=self.on_connect, on_disconnect=self.on_disconnect, authreconnect=LIBRARY_AUTORECONNECT_DISABLED, **kwargs) + self.event_obs = obsws(host=host, port=port, timeout=timeout, legacy=legacy, on_connect=self.on_connect, on_disconnect=self.on_disconnect, authreconnect=LIBRARY_AUTORECONNECT_DISABLED, **kwargs) self.connect() log.info("Successfully connected to OBS") return True except (obswebsocket.exceptions.ConnectionFailure, ValueError) as e: try: log.error(f"Failed to connect to OBS with legacy: {legacy}, trying with legacy: {not legacy}") - super().__init__(host=host, port=port, timeout=timeout, legacy=not legacy, on_connect=self.on_connect, on_disconnect=self.on_disconnect, authreconnect=5, **kwargs) - self.event_obs = obsws(host=host, port=port, timeout=timeout, legacy=not legacy, on_connect=self.on_connect, on_disconnect=self.on_disconnect, authreconnect=5, **kwargs) + super().__init__(host=host, port=port, timeout=timeout, legacy=not legacy, on_connect=self.on_connect, on_disconnect=self.on_disconnect, authreconnect=LIBRARY_AUTORECONNECT_DISABLED, **kwargs) + self.event_obs = obsws(host=host, port=port, timeout=timeout, legacy=not legacy, on_connect=self.on_connect, on_disconnect=self.on_disconnect, authreconnect=LIBRARY_AUTORECONNECT_DISABLED, **kwargs) self.connect() log.info("Successfully connected to OBS") diff --git a/backend/backend.py b/backend/backend.py index e14eafc..4826d6e 100644 --- a/backend/backend.py +++ b/backend/backend.py @@ -35,6 +35,34 @@ def get_connected(self) -> bool: def connect_to(self, *args, **kwargs): self.OBSController.connect_to(*args, **kwargs) + def mark_disconnected(self): + """Force the connection state to disconnected so the frontend retries the connection. + + The websocket library does not always report a closed OBS reliably (on_disconnect is + not guaranteed to fire), so we additionally treat any missing/empty response as a lost + connection. The frontend tick loop then reconnects on its own. + """ + self.OBSController.connected = False + + def build_status_dict(self, status, key_mapping: dict) -> dict: + """Build a response dict from an OBS request, mapping result keys to OBS response keys. + + Returns None (and marks the connection as lost) when the response is missing or its data + is empty, which is what happens while OBS is closed. This both drives the reconnect and + avoids a KeyError crashing the action tick. + """ + if status is None: + self.mark_disconnected() + return None + response_data = status.datain + result = {} + for result_key, obs_key in key_mapping.items(): + if obs_key not in response_data: + self.mark_disconnected() + return None + result[result_key] = response_data[obs_key] + return result + def get_controller(self) -> OBSController: """ Calling methods on the returned controller will raise a circular reference error from Pyro @@ -44,18 +72,17 @@ def get_controller(self) -> OBSController: # Streaming def get_stream_status(self) -> dict: status = self.OBSController.get_stream_status() - if status is None: - return - return { - "active": status.datain["outputActive"], - "reconnecting": status.datain["outputReconnecting"], - "timecode": status.datain["outputTimecode"], - "duration": status.datain["outputDuration"], - "congestion": status.datain["outputCongestion"], - "bytes": status.datain["outputBytes"], - "skipped_frames": status.datain["outputSkippedFrames"], - "total_frames": status.datain["outputTotalFrames"] + key_mapping = { + "active": "outputActive", + "reconnecting": "outputReconnecting", + "timecode": "outputTimecode", + "duration": "outputDuration", + "congestion": "outputCongestion", + "bytes": "outputBytes", + "skipped_frames": "outputSkippedFrames", + "total_frames": "outputTotalFrames" } + return self.build_status_dict(status, key_mapping) def toggle_stream(self): status = self.OBSController.toggle_stream() @@ -66,15 +93,14 @@ def toggle_stream(self): # Recording def get_record_status(self) -> dict: status = self.OBSController.get_record_status() - if status is None: - return - return { - "active": status.datain["outputActive"], - "paused": status.datain["outputPaused"], - "timecode": status.datain["outputTimecode"], - "duration": status.datain["outputDuration"], - "bytes": status.datain["outputBytes"] + key_mapping = { + "active": "outputActive", + "paused": "outputPaused", + "timecode": "outputTimecode", + "duration": "outputDuration", + "bytes": "outputBytes" } + return self.build_status_dict(status, key_mapping) def toggle_record(self): self.OBSController.toggle_record() @@ -85,11 +111,7 @@ def toggle_record_pause(self): # Replay Buffer def get_replay_buffer_status(self) -> dict: status = self.OBSController.get_replay_buffer_status() - if status is None: - return - return { - "active": status.datain["outputActive"] - } + return self.build_status_dict(status, {"active": "outputActive"}) def start_replay_buffer(self): self.OBSController.start_replay_buffer() @@ -103,11 +125,7 @@ def save_replay_buffer(self): # Virtual Camera def get_virtual_camera_status(self) -> dict: status = self.OBSController.get_virtual_camera_status() - if status is None: - return - return { - "active": status.datain["outputActive"] - } + return self.build_status_dict(status, {"active": "outputActive"}) def start_virtual_camera(self): self.OBSController.start_virtual_camera() @@ -118,11 +136,7 @@ def stop_virtual_camera(self): # Studio Mode def get_studio_mode_enabled(self) -> dict: status = self.OBSController.get_studio_mode_enabled() - if status is None: - return - return { - "active": status.datain["studioModeEnabled"] - } + return self.build_status_dict(status, {"active": "studioModeEnabled"}) def set_studio_mode_enabled(self, enabled: bool): self.OBSController.set_studio_mode_enabled(enabled) @@ -136,22 +150,14 @@ def get_inputs(self) -> list[str]: def get_input_muted(self, input: str): status = self.OBSController.get_input_muted(input) - if status is None: - return - return { - "muted": status.datain["inputMuted"] - } + return self.build_status_dict(status, {"muted": "inputMuted"}) def set_input_muted(self, input: str, muted: bool): self.OBSController.set_input_muted(input, muted) def get_input_volume(self, input: str): status = self.OBSController.get_input_volume(input) - if status is None: - return - return { - "volume": status.datain["inputVolumeDb"] - } + return self.build_status_dict(status, {"volume": "inputVolumeDb"}) def set_input_volume(self, input: str, volume: int): self.OBSController.set_input_volume(input, volume) @@ -169,11 +175,7 @@ def get_scene_items(self, sceneName: str) -> list[str]: def get_scene_item_enabled(self, sceneName: str, sourceName: str): status = self.OBSController.get_scene_item_enabled(sceneName, sourceName) - if status is None: - return - return { - "enabled": status.datain["sceneItemEnabled"] - } + return self.build_status_dict(status, {"enabled": "sceneItemEnabled"}) def set_scene_item_enabled(self, sceneName: str, sourceName: str, enabled: bool): self.OBSController.set_scene_item_enabled(sceneName, sourceName, enabled)