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:
+55
-125
@@ -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...")
|
||||
|
||||
Reference in New Issue
Block a user