Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions OBSActionBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
)
Expand Down Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions actions/Filter/FilterBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions actions/InputDial/InputDial.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}%"
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions actions/InputMute/InputMuteBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions actions/RecPlayPause/RecPlayPause.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
3 changes: 3 additions & 0 deletions actions/SceneItem/SceneItem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions actions/ToggleRecord/ToggleRecord.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions actions/ToggleReplayBuffer/ToggleReplayBuffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
1 change: 1 addition & 0 deletions actions/ToggleStream/ToggleStream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions actions/ToggleStudioMode/ToggleStudioMode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
3 changes: 3 additions & 0 deletions actions/ToggleVirtualCamera/ToggleVirtualCamera.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
32 changes: 28 additions & 4 deletions backend/OBSController.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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")

Expand Down
Loading