Files
SageTube/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_updater.py
T
Homer 089b144626 Correct the version and make the config merge deeply
The package version had been left at the scaffolded 0.1.0 while the released
tag was v5.4.0. Nothing in the code chose 0.1.0 -- it was npm-init's equivalent,
never updated -- and it made every version comparison meaningless: the window
title, the About dialog and the update check all read it.

ConfigManager merged a stored config over the defaults with dict.update(),
which is shallow. A config written by an older build carries partial "player"
and "feed" objects, and a shallow update replaces the whole nested default with
the partial one, so keys added since came back missing. The `or 15` and
`or "auto"` fallbacks at the call sites were load-bearing because of it. The
merge is now recursive, and keys present only in the stored file are kept so a
downgrade cannot destroy settings.

Configs now carry a config_version. A file written before 5.4.0 -- including
one inherited from an upstream YTSage install -- has its stored
check_app_updates cleared once, because that setting used to point at PyPI's
`ytsage` package and oop7/YTSage's releases, neither of which is this program.

The settings tab read a missing check_app_updates as enabled and persisted that
reading on OK, so merely opening Custom Options turned the checker back on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 00:31:56 +02:00

1079 lines
42 KiB
Python

"""
Updater tab for Custom Options dialog.
Handles FFmpeg version checking and yt-dlp auto-update settings.
"""
import re
import subprocess
import threading
from typing import Optional, Tuple, TYPE_CHECKING, cast
import requests
from PySide6.QtCore import Qt, Signal, QThread, Slot
from PySide6.QtWidgets import (
QCheckBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ...core.ytsage_utils import (
get_auto_update_settings,
get_ffmpeg_version_direct,
update_auto_update_settings,
)
from ...core.ytsage_yt_dlp import get_yt_dlp_path
from ...core.ytsage_deno import check_deno_update, upgrade_deno
from .ytsage_dialogs_update import YTDLPUpdateDialog
from ...utils.ytsage_config_manager import ConfigManager
from ...utils.ytsage_localization import _
from ...utils.ytsage_logger import logger
from ...utils.ytsage_constants import (
FFMPEG_7Z_VERSION_URL,
OS_NAME,
SUBPROCESS_CREATIONFLAGS,
)
if TYPE_CHECKING:
from .ytsage_dialogs_custom import CustomOptionsDialog
# Helper functions for FFmpeg version checking (copied from removed ytsage_ffmpeg_updater.py)
def get_latest_ffmpeg_version() -> Optional[str]:
"""
Fetch the latest FFmpeg version from the version URL.
Returns:
str: Version string (e.g., "8.0") or None if fetch failed
"""
try:
response = requests.get(FFMPEG_7Z_VERSION_URL, timeout=10)
response.raise_for_status()
version = response.text.strip()
# Validate version format (should be something like "8.0" or "7.1.1")
if re.match(r'^\d+\.\d+(\.\d+)?$', version):
logger.info(f"Latest FFmpeg version: {version}")
return version
else:
logger.warning(f"Unexpected version format: {version}")
return None
except requests.RequestException as e:
logger.error(f"Failed to fetch latest FFmpeg version: {e}")
return None
except Exception as e:
logger.exception(f"Unexpected error fetching FFmpeg version: {e}")
return None
def parse_version(version_str: str) -> Tuple[int, ...]:
"""
Parse version string into tuple of integers for comparison.
Args:
version_str: Version string like "8.0" or "7.1.1"
Returns:
Tuple of integers (e.g., (8, 0) or (7, 1, 1))
"""
try:
# Extract version numbers from string
match = re.search(r'(\d+\.\d+(?:\.\d+)?)', version_str)
if match:
version_str = match.group(1)
parts = version_str.split('.')
return tuple(int(p) for p in parts)
except (ValueError, AttributeError):
logger.warning(f"Could not parse version: {version_str}")
return (0,)
def compare_versions(current: str, latest: str) -> bool:
"""
Compare two version strings.
Args:
current: Current version string
latest: Latest version string
Returns:
True if update is needed (latest > current), False otherwise
"""
try:
current_tuple = parse_version(current)
latest_tuple = parse_version(latest)
logger.info(f"Comparing versions - Current: {current_tuple}, Latest: {latest_tuple}")
# Pad shorter version with zeros for comparison
max_len = max(len(current_tuple), len(latest_tuple))
current_padded = current_tuple + (0,) * (max_len - len(current_tuple))
latest_padded = latest_tuple + (0,) * (max_len - len(latest_tuple))
return latest_padded > current_padded
except Exception as e:
logger.exception(f"Error comparing versions: {e}")
return False
def check_ffmpeg_version() -> Tuple[bool, str, str]:
"""
Check FFmpeg version and compare with latest.
Returns:
Tuple of (update_available, current_version, latest_version)
"""
try:
# Get current version
current_version = get_ffmpeg_version_direct()
if current_version in ["Not found", "Error getting version", "Unknown version"]:
current_version = "Not installed"
# Get latest version
latest_version = get_latest_ffmpeg_version()
if latest_version is None:
latest_version = "Unknown"
return False, current_version, latest_version
# If not installed, update is available
if current_version == "Not installed":
return True, current_version, latest_version
# Compare versions
update_needed = compare_versions(current_version, latest_version)
return update_needed, current_version, latest_version
except Exception as e:
logger.exception(f"Error checking FFmpeg version: {e}")
return False, "Error", "Error"
class FFmpegCheckThread(QThread):
finished = Signal(bool, str, str)
error = Signal(str)
def run(self):
try:
update_available, current_version, latest_version = check_ffmpeg_version()
self.finished.emit(update_available, current_version, latest_version)
except Exception as e:
logger.exception(f"Error checking FFmpeg version: {e}")
self.error.emit(str(e))
class DenoCheckThread(QThread):
finished = Signal(bool, str, str)
error = Signal(str)
def run(self):
try:
update_available, current_version, latest_version = check_deno_update()
self.finished.emit(update_available, current_version, latest_version)
except Exception as e:
logger.exception(f"Error checking Deno version: {e}")
self.error.emit(str(e))
class DenoUpdateThread(QThread):
finished = Signal(bool, str)
progress = Signal(str)
error = Signal(str)
def run(self):
try:
success, output = upgrade_deno(progress_callback=self.progress.emit)
self.finished.emit(success, output)
except Exception as e:
logger.exception(f"Error updating Deno: {e}")
self.error.emit(str(e))
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."""
# Main layout for the tab
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
# Create scroll area
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QScrollArea.Shape.NoFrame)
scroll_area.setStyleSheet("QScrollArea { background: transparent; }")
# Create a widget to hold all content
content_widget = QWidget()
layout = QVBoxLayout(content_widget)
# Help text
help_text = QLabel(_('ffmpeg_updater.description'))
help_text.setWordWrap(True)
help_text.setStyleSheet("color: #999999; padding: 10px;")
layout.addWidget(help_text)
# FFmpeg Version Check Section
ffmpeg_group = QGroupBox(_('ffmpeg_updater.title'))
ffmpeg_layout = QVBoxLayout(ffmpeg_group)
# Version information layout
version_layout = QVBoxLayout()
# Current version
current_layout = QHBoxLayout()
current_label = QLabel(_('ffmpeg_updater.current_version'))
current_label.setStyleSheet("font-weight: bold; color: #ffffff;")
current_layout.addWidget(current_label)
self.current_version_label = QLabel("...")
self.current_version_label.setStyleSheet("color: #cccccc;")
current_layout.addWidget(self.current_version_label)
current_layout.addStretch()
version_layout.addLayout(current_layout)
# Latest version
latest_layout = QHBoxLayout()
latest_label = QLabel(_('ffmpeg_updater.latest_version'))
latest_label.setStyleSheet("font-weight: bold; color: #ffffff;")
latest_layout.addWidget(latest_label)
self.latest_version_label = QLabel("...")
self.latest_version_label.setStyleSheet("color: #cccccc;")
latest_layout.addWidget(self.latest_version_label)
latest_layout.addStretch()
version_layout.addLayout(latest_layout)
ffmpeg_layout.addLayout(version_layout)
# Status label
self.status_label = QLabel(_('ffmpeg_updater.status_idle'))
self.status_label.setWordWrap(True)
self.status_label.setStyleSheet(
"color: #888888; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
ffmpeg_layout.addWidget(self.status_label)
# Check version button
check_button_layout = QHBoxLayout()
self.check_button = QPushButton(_('ffmpeg_updater.check_updates'))
self.check_button.clicked.connect(self.check_for_updates)
self.check_button.setStyleSheet(
"""
QPushButton {
padding: 8px 15px;
background-color: #444444;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
min-width: 120px;
}
QPushButton:hover {
background-color: #555555;
}
QPushButton:disabled {
background-color: #2a2a2a;
color: #666666;
}
"""
)
check_button_layout.addWidget(self.check_button)
check_button_layout.addStretch()
ffmpeg_layout.addLayout(check_button_layout)
# Installation guide info
guide_label = QLabel(_('ffmpeg_updater.guide_info'))
guide_label.setWordWrap(True)
guide_label.setOpenExternalLinks(True) # Enable clickable links
guide_label.setTextFormat(Qt.TextFormat.RichText) # Enable HTML formatting
guide_label.setStyleSheet(
"""
QLabel {
color: #cccccc;
font-size: 11px;
padding: 8px;
background-color: #1a1d20;
border-radius: 4px;
}
QLabel a {
color: #4da6ff;
text-decoration: underline;
}
QLabel a:hover {
color: #66b3ff;
}
"""
)
ffmpeg_layout.addWidget(guide_label)
layout.addWidget(ffmpeg_group)
# === Deno Version Check & Update Section ===
deno_group = QGroupBox(_('deno_updater.title'))
deno_layout = QVBoxLayout(deno_group)
# Description
deno_desc = QLabel(_('deno_updater.description'))
deno_desc.setWordWrap(True)
deno_desc.setStyleSheet("color: #999999; padding: 10px;")
deno_layout.addWidget(deno_desc)
# Version information layout
deno_version_layout = QVBoxLayout()
# Current version
deno_current_layout = QHBoxLayout()
deno_current_label = QLabel(_('deno_updater.current_version'))
deno_current_label.setStyleSheet("font-weight: bold; color: #ffffff;")
deno_current_layout.addWidget(deno_current_label)
self.deno_current_version_label = QLabel("...")
self.deno_current_version_label.setStyleSheet("color: #cccccc;")
deno_current_layout.addWidget(self.deno_current_version_label)
deno_current_layout.addStretch()
deno_version_layout.addLayout(deno_current_layout)
# Latest version
deno_latest_layout = QHBoxLayout()
deno_latest_label = QLabel(_('deno_updater.latest_version'))
deno_latest_label.setStyleSheet("font-weight: bold; color: #ffffff;")
deno_latest_layout.addWidget(deno_latest_label)
self.deno_latest_version_label = QLabel("...")
self.deno_latest_version_label.setStyleSheet("color: #cccccc;")
deno_latest_layout.addWidget(self.deno_latest_version_label)
deno_latest_layout.addStretch()
deno_version_layout.addLayout(deno_latest_layout)
deno_layout.addLayout(deno_version_layout)
# Status label
self.deno_status_label = QLabel(_('deno_updater.status_idle'))
self.deno_status_label.setWordWrap(True)
self.deno_status_label.setStyleSheet(
"color: #888888; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
deno_layout.addWidget(self.deno_status_label)
# Buttons layout
deno_button_layout = QHBoxLayout()
# Check for updates button
self.deno_check_button = QPushButton(_('deno_updater.check_updates'))
self.deno_check_button.clicked.connect(self.check_deno_updates)
self.deno_check_button.setStyleSheet(
"""
QPushButton {
padding: 8px 15px;
background-color: #444444;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
min-width: 120px;
}
QPushButton:hover {
background-color: #555555;
}
QPushButton:disabled {
background-color: #2a2a2a;
color: #666666;
}
"""
)
deno_button_layout.addWidget(self.deno_check_button)
# Update button (initially hidden)
self.deno_update_button = QPushButton(_('deno_updater.update_now'))
self.deno_update_button.clicked.connect(self.update_deno)
self.deno_update_button.setVisible(False)
self.deno_update_button.setStyleSheet(
"""
QPushButton {
padding: 8px 15px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
min-width: 120px;
}
QPushButton:hover {
background-color: #a50000;
}
QPushButton:pressed {
background-color: #800000;
}
QPushButton:disabled {
background-color: #2a2a2a;
color: #666666;
}
"""
)
deno_button_layout.addWidget(self.deno_update_button)
deno_button_layout.addStretch()
deno_layout.addLayout(deno_button_layout)
layout.addWidget(deno_group)
# === App Updates Section ===
app_update_group = QGroupBox(_("settings.app_updates_title"))
app_update_layout = QVBoxLayout()
self.app_updates_checkbox = QCheckBox(_("settings.check_app_updates"))
self.app_updates_checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
spacing: 5px;
padding: 3px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #1d1e22;
border-radius: 9px;
}
QCheckBox::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
border-radius: 9px;
}
"""
)
app_update_layout.addWidget(self.app_updates_checkbox)
self.beta_updates_checkbox = QCheckBox(_("settings.check_beta_updates"))
self.beta_updates_checkbox.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
spacing: 5px;
padding: 3px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #1d1e22;
border-radius: 9px;
}
QCheckBox::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
border-radius: 9px;
}
"""
)
app_update_layout.addWidget(self.beta_updates_checkbox)
app_update_group.setLayout(app_update_layout)
layout.addWidget(app_update_group)
# === yt-dlp Release Channel Section ===
ytdlp_channel_group = QGroupBox(_("settings.ytdlp_channel"))
ytdlp_channel_layout = QVBoxLayout()
ytdlp_channel_layout.setSpacing(5) # Reduce spacing
ytdlp_channel_layout.setContentsMargins(10, 10, 10, 10) # Reduce margins
# Description
channel_desc = QLabel(_("settings.ytdlp_channel_description"))
channel_desc.setWordWrap(True)
channel_desc.setStyleSheet("color: #cccccc; font-size: 11px; padding: 2px;")
ytdlp_channel_layout.addWidget(channel_desc)
# Radio buttons for channel selection
self.channel_stable_radio = QRadioButton(_("settings.ytdlp_channel_stable"))
self.channel_nightly_radio = QRadioButton(_("settings.ytdlp_channel_nightly"))
radio_button_style = """
QRadioButton {
color: #ffffff;
spacing: 5px;
padding: 3px;
}
QRadioButton::indicator {
width: 16px;
height: 16px;
border-radius: 8px;
}
QRadioButton::indicator:unchecked {
border: 2px solid #666666;
background: #15181b;
}
QRadioButton::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
}
"""
self.channel_stable_radio.setStyleSheet(radio_button_style)
self.channel_nightly_radio.setStyleSheet(radio_button_style)
# Connect radio button signals
self.channel_stable_radio.toggled.connect(self._on_channel_changed)
self.channel_nightly_radio.toggled.connect(self._on_channel_changed)
ytdlp_channel_layout.addWidget(self.channel_stable_radio)
ytdlp_channel_layout.addWidget(self.channel_nightly_radio)
# Status label for channel operations
self.channel_status_label = QLabel("")
self.channel_status_label.setWordWrap(True)
self.channel_status_label.setStyleSheet(
"color: #888888; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
self.channel_status_label.setVisible(False)
ytdlp_channel_layout.addWidget(self.channel_status_label)
ytdlp_channel_group.setLayout(ytdlp_channel_layout)
layout.addWidget(ytdlp_channel_group)
# === Auto-Update yt-dlp Section ===
auto_update_group_box = QGroupBox(_("settings.auto_update_ytdlp"))
auto_update_layout = QVBoxLayout()
# Enable/Disable auto-update checkbox
self.auto_update_enabled = QCheckBox(_("settings.enable_auto_updates"))
self.auto_update_enabled.setStyleSheet(
"""
QCheckBox {
color: #ffffff;
spacing: 5px;
padding: 3px;
}
QCheckBox::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
}
QCheckBox::indicator:unchecked {
border: 2px solid #666666;
background: #1d1e22;
border-radius: 9px;
}
QCheckBox::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
border-radius: 9px;
}
QCheckBox:disabled { color: #888888; }
QCheckBox::indicator:disabled { border-color: #555555; background: #444444; }
"""
)
auto_update_layout.addWidget(self.auto_update_enabled)
# Frequency options
frequency_label = QLabel(_("settings.update_frequency"))
frequency_label.setStyleSheet("color: #ffffff; margin-top: 10px;")
auto_update_layout.addWidget(frequency_label)
self.startup_radio = QRadioButton(_("settings.check_startup"))
self.daily_radio = QRadioButton(_("settings.check_daily"))
self.weekly_radio = QRadioButton(_("settings.check_weekly"))
self.startup_radio.setStyleSheet(
"""
QRadioButton {
color: #ffffff;
spacing: 5px;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
}
QRadioButton::indicator:unchecked {
border: 2px solid #666666;
background: #15181b;
}
QRadioButton::indicator:checked {
border: 2px solid #c90000;
background: #c90000;
}
"""
)
self.daily_radio.setStyleSheet(self.startup_radio.styleSheet())
self.weekly_radio.setStyleSheet(self.startup_radio.styleSheet())
auto_update_layout.addWidget(self.startup_radio)
auto_update_layout.addWidget(self.daily_radio)
auto_update_layout.addWidget(self.weekly_radio)
# Test update button
test_update_layout = QHBoxLayout()
test_update_button = QPushButton(_("settings.check_updates_now"))
test_update_button.clicked.connect(self.test_update_check)
test_update_button.setStyleSheet(
"""
QPushButton {
padding: 8px 15px;
background-color: #c90000;
border: none;
border-radius: 4px;
color: white;
font-weight: bold;
min-width: 120px;
}
QPushButton:hover {
background-color: #a50000;
}
QPushButton:pressed {
background-color: #800000;
}
"""
)
test_update_layout.addWidget(test_update_button)
test_update_layout.addStretch()
auto_update_layout.addLayout(test_update_layout)
auto_update_group_box.setLayout(auto_update_layout)
layout.addWidget(auto_update_group_box)
layout.addStretch()
# Set the content widget to the scroll area
scroll_area.setWidget(content_widget)
# Add scroll area to main layout
main_layout.addWidget(scroll_area)
def _load_auto_update_settings(self) -> None:
"""Load current auto-update settings for yt-dlp."""
try:
auto_settings = get_auto_update_settings()
# Set checkbox
self.auto_update_enabled.setChecked(auto_settings["enabled"])
# Load beta setting
beta_enabled = ConfigManager.get("check_beta_updates") or False
self.beta_updates_checkbox.setChecked(beta_enabled)
# Load app update checker setting. `is not False` would have read a
# missing key as enabled, and since this dialog persists the value
# unconditionally on OK, merely opening it re-enabled the checker.
app_updates_enabled = ConfigManager.get("check_app_updates")
self.app_updates_checkbox.setChecked(bool(app_updates_enabled))
# Set current selection based on saved settings
current_frequency = auto_settings["frequency"]
if current_frequency == "startup":
self.startup_radio.setChecked(True)
elif current_frequency == "daily":
self.daily_radio.setChecked(True)
else: # weekly
self.weekly_radio.setChecked(True)
# Load channel setting
channel = ConfigManager.get("ytdlp_channel")
if channel is None:
channel = "stable" # Default to stable if not set
if channel == "nightly":
self.channel_nightly_radio.setChecked(True)
else:
self.channel_stable_radio.setChecked(True)
# Update status label
self._update_channel_status(channel)
except Exception as e:
logger.exception(f"Error loading auto-update settings: {e}")
def get_auto_update_settings(self) -> tuple[bool, str]:
"""Returns the auto-update settings from the dialog."""
enabled = self.auto_update_enabled.isChecked()
if self.startup_radio.isChecked():
frequency = "startup"
elif self.daily_radio.isChecked():
frequency = "daily"
else: # weekly_radio is checked
frequency = "weekly"
return enabled, frequency
def get_beta_update_setting(self) -> bool:
"""Returns the beta update setting from the dialog."""
return self.beta_updates_checkbox.isChecked()
def get_app_update_checker_setting(self) -> bool:
"""Returns the app version checker setting from the dialog."""
return self.app_updates_checkbox.isChecked()
def _on_channel_changed(self, checked: bool) -> None:
"""Handle channel selection change."""
if not checked:
return
# Determine which channel was selected
new_channel = "nightly" if self.channel_nightly_radio.isChecked() else "stable"
current_channel = ConfigManager.get("ytdlp_channel")
if current_channel is None:
current_channel = "stable"
# If channel hasn't actually changed, just update status
if new_channel == current_channel:
self._update_channel_status(new_channel)
return
# Show switching message
self.channel_status_label.setText(_("settings.ytdlp_switching_channel", channel=new_channel))
self.channel_status_label.setStyleSheet(
"color: #ffaa00; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
self.channel_status_label.setVisible(True)
# Disable radio buttons during switch
self.channel_stable_radio.setEnabled(False)
self.channel_nightly_radio.setEnabled(False)
# Run channel switch in background thread
def switch_channel():
try:
# Get yt-dlp path
yt_dlp_path = get_yt_dlp_path()
# Build the update command
# When switching to stable from nightly, we need to get the latest stable version
# to force the switch even if versions have the same date
update_target = new_channel
if new_channel == "stable" and current_channel == "nightly":
# Get the latest stable version tag from PyPI (no rate limiting)
logger.info("Fetching latest stable version tag from PyPI...")
try:
import requests
response = requests.get("https://pypi.org/pypi/yt-dlp/json", timeout=10)
response.raise_for_status()
latest_tag = response.json()["info"]["version"]
if latest_tag:
# PyPI returns version without zero-padding (e.g. "2026.2.21")
# but yt-dlp GitHub tags are zero-padded (e.g. "2026.02.21")
parts = latest_tag.split(".")
if len(parts) == 3:
latest_tag = f"{parts[0]}.{int(parts[1]):02d}.{int(parts[2]):02d}"
update_target = f"stable@{latest_tag}"
logger.info(f"Latest stable version tag: {latest_tag}")
else:
logger.warning("Could not determine latest stable tag, using 'stable'")
except Exception as e:
logger.warning(f"Failed to fetch latest stable tag, using 'stable': {e}")
# Run the update-to command
logger.info(f"Switching yt-dlp to {new_channel} channel...")
logger.debug(f"Running command: {yt_dlp_path} --update-to {update_target}")
result = subprocess.run(
[yt_dlp_path, "--update-to", update_target],
capture_output=True,
text=True,
timeout=120,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
# Log the output for debugging
if result.stdout:
logger.debug(f"yt-dlp stdout: {result.stdout.strip()}")
if result.stderr:
logger.debug(f"yt-dlp stderr: {result.stderr.strip()}")
logger.debug(f"yt-dlp return code: {result.returncode}")
if result.returncode == 0:
# Success - save the preference
ConfigManager.set("ytdlp_channel", new_channel)
logger.info(f"Successfully switched to {new_channel} channel")
# 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:
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_switch_finished.emit(False, new_channel, current_channel, error_msg)
except subprocess.TimeoutExpired:
logger.error("Channel switch timed out")
self._channel_switch_finished.emit(False, new_channel, current_channel, "Timeout")
except Exception as e:
logger.exception(f"Error switching channel: {e}")
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))
self.channel_status_label.setStyleSheet(
"color: #888888; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
self.channel_status_label.setVisible(True)
def test_update_check(self) -> None:
"""Open the yt-dlp update dialog with proper progress tracking."""
# Create and show the update dialog (non-modal to prevent blocking)
dialog = YTDLPUpdateDialog(self)
dialog.setModal(False) # Make it non-modal
dialog.show() # Use show() instead of exec() to avoid blocking
def check_for_updates(self) -> None:
"""Check FFmpeg version and compare with latest."""
self.check_button.setEnabled(False)
self.status_label.setText(_('ffmpeg_updater.status_checking'))
self.status_label.setStyleSheet(
"color: #ffaa00; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
self.ffmpeg_check_thread = FFmpegCheckThread()
self.ffmpeg_check_thread.finished.connect(self._on_ffmpeg_check_finished)
self.ffmpeg_check_thread.error.connect(self._on_ffmpeg_check_error)
self.ffmpeg_check_thread.start()
@Slot(bool, str, str)
def _on_ffmpeg_check_finished(self, update_available, current_version, latest_version):
self.check_button.setEnabled(True)
self._update_check_results(update_available, current_version, latest_version)
@Slot(str)
def _on_ffmpeg_check_error(self, error):
self.check_button.setEnabled(True)
self._show_check_error(error)
def _update_check_results(self, update_available: bool, current_version: str, latest_version: str) -> None:
"""Handle completion of version check."""
self.update_available = update_available
self.current_version = current_version
self.latest_version = latest_version
# Update version labels
self.current_version_label.setText(current_version)
self.latest_version_label.setText(latest_version)
# Update status
if current_version == "Not installed":
self.status_label.setText(_('ffmpeg_updater.status_not_installed'))
self.status_label.setStyleSheet(
"color: #ff6666; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
elif update_available:
self.status_label.setText(_('ffmpeg_updater.status_update_available'))
self.status_label.setStyleSheet(
"color: #ffaa00; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
else:
self.status_label.setText(_('ffmpeg_updater.status_up_to_date'))
self.status_label.setStyleSheet(
"color: #00cc00; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
def _show_check_error(self, error: str) -> None:
"""Handle error during version check."""
self.status_label.setText(_('ffmpeg_updater.check_failed'))
self.status_label.setStyleSheet(
"color: #ff6666; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
def check_deno_updates(self) -> None:
"""Check for Deno updates."""
self.deno_check_button.setEnabled(False)
self.deno_update_button.setVisible(False)
self.deno_status_label.setText(_('deno_updater.status_checking'))
self.deno_status_label.setStyleSheet(
"color: #ffaa00; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
self.deno_check_thread = DenoCheckThread()
self.deno_check_thread.finished.connect(self._on_deno_check_finished)
self.deno_check_thread.error.connect(self._on_deno_check_error)
self.deno_check_thread.start()
@Slot(bool, str, str)
def _on_deno_check_finished(self, update_available, current_version, latest_version):
self.deno_check_button.setEnabled(True)
self._update_deno_check_results(update_available, current_version, latest_version)
@Slot(str)
def _on_deno_check_error(self, error):
self.deno_check_button.setEnabled(True)
self._show_deno_check_error(error)
def _update_deno_check_results(self, update_available: bool, current_version: str, latest_version: str) -> None:
"""Handle completion of Deno version check."""
# Update version labels
self.deno_current_version_label.setText(current_version)
self.deno_latest_version_label.setText(latest_version)
# Update status
if current_version in ["Not found", "Error getting version"]:
self.deno_status_label.setText(_('deno_updater.status_not_installed'))
self.deno_status_label.setStyleSheet(
"color: #ff6666; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
elif update_available:
self.deno_status_label.setText(_('deno_updater.status_update_available'))
self.deno_status_label.setStyleSheet(
"color: #ffaa00; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
# Show update button
self.deno_update_button.setVisible(True)
else:
self.deno_status_label.setText(_('deno_updater.status_up_to_date'))
self.deno_status_label.setStyleSheet(
"color: #00cc00; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
def _show_deno_check_error(self, error: str) -> None:
"""Handle error during Deno version check."""
self.deno_status_label.setText(_('deno_updater.check_failed'))
self.deno_status_label.setStyleSheet(
"color: #ff6666; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
def update_deno(self) -> None:
"""Update Deno to the latest version."""
self.deno_check_button.setEnabled(False)
self.deno_update_button.setEnabled(False)
self.deno_status_label.setText(_('deno_updater.updating'))
self.deno_status_label.setStyleSheet(
"color: #ffaa00; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
# Use QThread for updates with progress reporting
self.deno_update_thread = DenoUpdateThread()
self.deno_update_thread.progress.connect(self._on_deno_update_progress)
self.deno_update_thread.finished.connect(self._on_deno_update_finished)
self.deno_update_thread.error.connect(self._on_deno_update_error)
self.deno_update_thread.start()
@Slot(str)
def _on_deno_update_progress(self, message: str) -> None:
"""Handle Deno update progress messages."""
# Clean up message for display
# Strip ANSI escape codes (colors)
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
display_msg = ansi_escape.sub('', message).strip()
if not display_msg:
return
# If message is too long, truncate it
if len(display_msg) > 70:
display_msg = display_msg[:67] + "..."
self.deno_status_label.setText(display_msg)
@Slot(bool, str)
def _on_deno_update_finished(self, success: bool, output: str) -> None:
self.deno_check_button.setEnabled(True)
self.deno_update_button.setEnabled(True)
self._handle_deno_update_result(success, output)
@Slot(str)
def _on_deno_update_error(self, error: str) -> None:
self.deno_check_button.setEnabled(True)
self.deno_update_button.setEnabled(True)
self._handle_deno_update_result(False, error)
def _handle_deno_update_result(self, success: bool, output: str) -> None:
"""Handle Deno update completion."""
if success:
self.deno_status_label.setText(_('deno_updater.update_success'))
self.deno_status_label.setStyleSheet(
"color: #00cc00; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
self.deno_update_button.setVisible(False)
# Re-check version to update display
self.check_deno_updates()
else:
# Extract meaningful error from output
error_msg = output if output else "Unknown error"
self.deno_status_label.setText(_('deno_updater.update_failed', error=error_msg))
self.deno_status_label.setStyleSheet(
"color: #ff6666; font-size: 12px; padding: 8px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)