Check for SageTube updates, not YTSage's

The update check asked PyPI for the `ytsage` package's version, asked GitHub
for oop7/YTSage's release notes, and pointed the download button at upstream's
releases. None of that describes this program. Being reminded to install
YTSage was the visible symptom; the cause was that the check had never been
repointed when the fork was made.

It now reads SageTube's own releases from git.houmeres.sk. Gitea's release API
is shaped like GitHub's, so the dialog and the caller are unchanged -- the
class keeps its name and signal signature, and only its body moved out to
core/ytsage_app_update.py, which is fork-owned and will not conflict on the
next merge from upstream.

Upstream's inherited tags end in `b` (v5.3.0b and earlier). packaging reads
that as a beta marker, so they sort below v5.4.0 and a stable instance cannot
be handed one. Tag parsing is defensive anyway: one unparseable tag must not
take the whole check down with it.

Also: the check is rate-limited to once a day rather than every start, the
dialog gained a "Skip this version" that survives a restart, the thread is now
joined on close, and the About dialog says SageTube. The three binary updaters
-- yt-dlp, Deno, ffmpeg -- legitimately track their own upstreams and are
deliberately untouched; ytsage_app_update's docstring says so, because "update"
is an overloaded word in this codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 00:36:14 +02:00
parent 089b144626
commit 2420091a4e
7 changed files with 294 additions and 132 deletions
+55 -125
View File
@@ -5,8 +5,6 @@ import webbrowser
from pathlib import Path
import markdown
import requests
from packaging import version
from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot, QThread, Signal, QUrl, QPropertyAnimation, QEasingCurve, QPoint
from PySide6.QtGui import QIcon
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
@@ -35,6 +33,7 @@ from PySide6.QtWidgets import (
from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor
from .. import __version__ as APP_VERSION
from ..core import ytsage_app_update as app_update # SageTube's own release check
from ..core.ytsage_downloader import DownloadThread, SignalManager # Import downloader related classes
from ..core.ytsage_utils import check_ffmpeg, load_saved_path, parse_yt_dlp_error, save_path, should_check_for_auto_update, validate_video_url
from ..core.ytsage_yt_dlp import get_yt_dlp_path, setup_ytdlp # Import the new yt-dlp functions
@@ -68,141 +67,46 @@ from ..utils.ytsage_localization import LocalizationManager, _
from ..utils.ytsage_history_manager import HistoryManager
from .ytsage_stylesheet import StyleSheet
from concurrent.futures import ThreadPoolExecutor, as_completed
class UpdateCheckThread(QThread):
"""Background thread for checking application updates with parallel network requests."""
update_available = Signal(str, str, str) # version, url, changelog
"""
Background check for a newer **SageTube** release.
# Reduced timeouts for faster failure detection
PYPI_TIMEOUT = 8
GITHUB_TIMEOUT = 5
This used to compare against PyPI's `ytsage` package and link to
oop7/YTSage's releases -- upstream's release stream, not this program's.
Combined with a package version that had been left at 0.1.0, it reported an
update on every start and sent the user to another project's downloads.
The forge query lives in core/ytsage_app_update.py. This class is only the
thread wrapper, and keeps its name and `update_available` signature so the
dialog and the caller are untouched.
"""
update_available = Signal(str, str, str) # version, url, changelog
def __init__(self, current_version):
super().__init__()
self.current_version = current_version
def _fetch_pypi_version(self) -> tuple[str | None, str | None]:
"""Fetch latest version from PyPI. Returns (version, error)."""
try:
response = requests.get(
"https://pypi.org/pypi/ytsage/json",
timeout=self.PYPI_TIMEOUT,
)
response.raise_for_status()
pypi_data = response.json()
return pypi_data["info"]["version"], None
except requests.Timeout:
return None, "PyPI request timed out"
except requests.RequestException as e:
return None, f"PyPI request failed: {e}"
except Exception as e:
return None, f"Error parsing PyPI response: {e}"
def _fetch_github_changelog(self) -> str:
"""Fetch changelog from GitHub. Returns changelog text or fallback message."""
fallback = "View the full changelog on the [GitHub Releases](https://github.com/oop7/YTSage/releases) page."
try:
response = requests.get(
"https://api.github.com/repos/oop7/YTSage/releases/latest",
headers={"Accept": "application/vnd.github.v3+json"},
timeout=self.GITHUB_TIMEOUT,
)
if response.status_code == 200:
gh_data = response.json()
return gh_data.get("body", fallback) or fallback
return fallback
except Exception:
# Silently fallback if GitHub API fails (rate limiting, network issues, etc.)
return fallback
def _fetch_github_beta_version(self) -> tuple[str | None, str | None, str | None]:
"""Fetch latest version code from GitHub releases (including betas). Returns (version, tag, changelog)."""
try:
response = requests.get(
"https://api.github.com/repos/oop7/YTSage/releases",
headers={"Accept": "application/vnd.github.v3+json"},
timeout=self.GITHUB_TIMEOUT,
)
if response.status_code != 200:
logger.debug(f"GitHub Releases API returned {response.status_code}")
return None, None, None
releases = response.json()
if not releases:
return None, None, None
latest_release = None
highest_ver = version.parse("0.0.0")
for rel in releases:
tag = rel.get("tag_name", "")
ver_str = tag.lstrip("v")
try:
v = version.parse(ver_str)
if v > highest_ver:
highest_ver = v
latest_release = rel
except Exception:
continue
if latest_release:
return str(highest_ver), latest_release.get("tag_name"), latest_release.get("body")
return None, None, None
except Exception as e:
logger.debug(f"GitHub beta check error: {e}")
return None, None, None
def run(self):
"""Check for updates using parallel network requests for better performance."""
try:
# Check for beta updates if enabled
check_beta = ConfigManager.get("check_beta_updates")
if check_beta:
latest_ver_str, tag, changelog = self._fetch_github_beta_version()
if latest_ver_str and version.parse(latest_ver_str) > version.parse(self.current_version):
release_url = f"https://github.com/oop7/YTSage/releases/tag/{tag}"
if not changelog:
changelog = "View the full changelog on GitHub."
self.update_available.emit(latest_ver_str, release_url, changelog)
# Return if beta check completes (whether update found or not),
# effectively skipping PyPI check if beta is enabled.
# This ensures we don't downgrade or conflict.
release = app_update.fetch_latest(
include_prerelease=bool(ConfigManager.get("check_beta_updates"))
)
app_update.mark_checked()
if release is None:
logger.debug("Update check: nothing newer published.")
return
if not app_update.is_newer(release, self.current_version):
logger.info(f"Update check: {self.current_version} is current (latest {release.tag}).")
return
# Use ThreadPoolExecutor to make both requests in parallel
# This reduces total wait time from potentially 15s to ~8s max
with ThreadPoolExecutor(max_workers=2) as executor:
# Submit both tasks
pypi_future = executor.submit(self._fetch_pypi_version)
github_future = executor.submit(self._fetch_github_changelog)
# Get PyPI result (this is required)
latest_version, error = pypi_future.result()
if error:
logger.debug(f"Update check failed: {error}")
return
if not latest_version:
logger.debug("No version returned from PyPI")
return
# Compare versions
if version.parse(latest_version) > version.parse(self.current_version):
release_url = "https://github.com/oop7/YTSage/releases/latest"
# Get GitHub changelog (may already be complete due to parallel execution)
changelog = github_future.result()
self.update_available.emit(latest_version, release_url, changelog)
changelog = release.body or _("update_dialog.changelog_unavailable")
logger.info(f"Update available: {release.tag}")
self.update_available.emit(str(release.version), release.url, changelog)
except Exception as e:
# An update check is never worth taking the app down for.
logger.debug(f"Failed to check for updates: {e}")
@@ -1087,9 +991,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
def check_for_updates(self) -> None:
"""Starts the update check in a background thread."""
if ConfigManager.get("check_app_updates") is False:
if not ConfigManager.get("check_app_updates"):
logger.info("App version checker is disabled in settings.")
return
if not app_update.should_check():
logger.debug("App version checked recently; skipping.")
return
self.update_thread = UpdateCheckThread(self.version)
self.update_thread.update_available.connect(self.show_update_dialog)
@@ -1181,9 +1088,15 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
remind_btn.clicked.connect(msg.close)
remind_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_REMIND_BTN)
skip_btn = QPushButton(_('update_dialog.skip_version'))
skip_btn.setToolTip(_('update_dialog.skip_version_tooltip', version=latest_version))
skip_btn.clicked.connect(lambda: self._skip_update_version(latest_version, msg))
skip_btn.setStyleSheet(StyleSheet.UPDATE_DIALOG_REMIND_BTN)
button_layout.addStretch()
button_layout.addWidget(download_btn)
button_layout.addWidget(remind_btn)
button_layout.addWidget(skip_btn)
layout.addLayout(button_layout)
# Style the dialog with improved theme matching
@@ -1191,6 +1104,12 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.run_dialog_with_blur(msg)
def _skip_update_version(self, latest_version: str, dialog: QDialog) -> None:
"""Never offer this particular version again; later ones still appear."""
ConfigManager.set("skipped_update_version", str(latest_version))
logger.info(f"Update {latest_version} skipped by the user.")
dialog.close()
def open_release_page(self, url):
webbrowser.open(url)
@@ -1266,6 +1185,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self._analysis_thread.terminate()
self._analysis_thread.wait(1000)
# Stop the app version check if it's still waiting on the forge.
# It holds no resources, but a QThread destroyed while running
# aborts the process on the way out.
if hasattr(self, "update_thread") and self.update_thread is not None and self.update_thread.isRunning():
logger.info("Stopping app update check thread...")
self.update_thread.quit()
if not self.update_thread.wait(3000):
logger.warning("Force terminating app update check thread...")
self.update_thread.terminate()
self.update_thread.wait(1000)
# Stop the auto-update thread if it's running
if hasattr(self, "auto_update_thread") and self.auto_update_thread.isRunning():
logger.info("Stopping auto-update thread...")