From 5064b383154f552640e03b7ef4343dd4b4997f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 25 Jul 2026 01:26:00 +0200 Subject: [PATCH] Marshal channel-switch UI updates back to the GUI thread The yt-dlp stable/nightly channel switcher mutated QLabel/QRadioButton state directly from a raw threading.Thread, which is undefined behavior in Qt. The worker now only runs the subprocess and emits a signal; the connected slot applies all widget updates on the GUI thread via Qt's queued delivery. Co-Authored-By: Claude Fable 5 --- .../ytsage_dialogs_updater.py | 95 +++++++++---------- 1 file changed, 43 insertions(+), 52 deletions(-) diff --git a/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_updater.py b/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_updater.py index fd0ff6b..7d89a96 100644 --- a/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_updater.py +++ b/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_updater.py @@ -200,18 +200,23 @@ class DenoUpdateThread(QThread): class UpdaterTabWidget(QWidget): """Widget for the Updater tab in Custom Options dialog.""" - + + # Emitted from the channel-switch worker thread; queued back to the GUI + # thread so widget updates never happen off-thread. + _channel_switch_finished = Signal(bool, str, str, str) # success, new_channel, current_channel, error + def __init__(self, parent=None) -> None: super().__init__(parent) self._parent: "CustomOptionsDialog" = cast("CustomOptionsDialog", self.parent()) - + # State variables self.current_version = "Unknown" self.latest_version = "Unknown" self.update_available = False - + self._init_ui() self._load_auto_update_settings() + self._channel_switch_finished.connect(self._on_channel_switch_finished) def _init_ui(self) -> None: """Initialize the UI components.""" @@ -815,69 +820,55 @@ class UpdaterTabWidget(QWidget): # Success - save the preference ConfigManager.set("ytdlp_channel", new_channel) logger.info(f"Successfully switched to {new_channel} channel") - - # Update UI - self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel)) - self.channel_status_label.setStyleSheet( - "color: #00cc00; font-size: 11px; padding: 5px; " - "background-color: #2a2d36; border-radius: 4px; margin: 5px 0;" - ) - + # Make executable on Unix systems if OS_NAME != "Windows": import os os.chmod(yt_dlp_path, 0o755) + + self._channel_switch_finished.emit(True, new_channel, current_channel, "") else: - # Failed - revert radio button error_msg = result.stderr.strip() if result.stderr else result.stdout.strip() if result.stdout else "Unknown error" logger.error(f"Failed to switch channel: {error_msg}") - - self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg)) - self.channel_status_label.setStyleSheet( - "color: #ff6666; font-size: 11px; padding: 5px; " - "background-color: #2a2d36; border-radius: 4px; margin: 5px 0;" - ) - - # Revert radio selection - if current_channel == "nightly": - self.channel_nightly_radio.setChecked(True) - else: - self.channel_stable_radio.setChecked(True) - + self._channel_switch_finished.emit(False, new_channel, current_channel, error_msg) + except subprocess.TimeoutExpired: logger.error("Channel switch timed out") - self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error="Timeout")) - self.channel_status_label.setStyleSheet( - "color: #ff6666; font-size: 11px; padding: 5px; " - "background-color: #2a2d36; border-radius: 4px; margin: 5px 0;" - ) - # Revert radio selection - if current_channel == "nightly": - self.channel_nightly_radio.setChecked(True) - else: - self.channel_stable_radio.setChecked(True) - + self._channel_switch_finished.emit(False, new_channel, current_channel, "Timeout") except Exception as e: logger.exception(f"Error switching channel: {e}") - self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=str(e))) - self.channel_status_label.setStyleSheet( - "color: #ff6666; font-size: 11px; padding: 5px; " - "background-color: #2a2d36; border-radius: 4px; margin: 5px 0;" - ) - # Revert radio selection - if current_channel == "nightly": - self.channel_nightly_radio.setChecked(True) - else: - self.channel_stable_radio.setChecked(True) - finally: - # Re-enable radio buttons - self.channel_stable_radio.setEnabled(True) - self.channel_nightly_radio.setEnabled(True) - + self._channel_switch_finished.emit(False, new_channel, current_channel, str(e)) + # Start the thread thread = threading.Thread(target=switch_channel, daemon=True) thread.start() - + + @Slot(bool, str, str, str) + def _on_channel_switch_finished(self, success: bool, new_channel: str, current_channel: str, error_msg: str) -> None: + """Apply the channel-switch outcome to the UI (always on the GUI thread).""" + if success: + self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel)) + self.channel_status_label.setStyleSheet( + "color: #00cc00; font-size: 11px; padding: 5px; " + "background-color: #2a2d36; border-radius: 4px; margin: 5px 0;" + ) + else: + self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg)) + self.channel_status_label.setStyleSheet( + "color: #ff6666; font-size: 11px; padding: 5px; " + "background-color: #2a2d36; border-radius: 4px; margin: 5px 0;" + ) + # Revert radio selection + if current_channel == "nightly": + self.channel_nightly_radio.setChecked(True) + else: + self.channel_stable_radio.setChecked(True) + + # Re-enable radio buttons + self.channel_stable_radio.setEnabled(True) + self.channel_nightly_radio.setEnabled(True) + + def _update_channel_status(self, channel: str) -> None: """Update the channel status label.""" self.channel_status_label.setText(_("settings.ytdlp_current_channel", channel=channel))