diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f56eb3..70b344d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,25 @@ records.
## Unreleased
+### Changed
+
+- **The update check now looks at SageTube's own releases.** It queried PyPI's
+ `ytsage` package for the version and `oop7/YTSage` for the changelog, then
+ linked to upstream's downloads — a different program's release stream. It now
+ reads `git.houmeres.sk/Houmeres/SageTube` releases, anonymously, and the
+ "Download update" button opens this repository's release page.
+- The beta channel follows SageTube pre-releases. Upstream's inherited `b` tags
+ (`v5.3.0b` and earlier) read as pre-releases, so a stable instance cannot be
+ offered one.
+- The check now runs at most once a day rather than on every start.
+- The About dialog and the update settings say SageTube rather than YTSage. The
+ "Based on YTSage by oop7" attribution link stays — it is the MIT credit.
+
+### Added
+
+- **Skip this version** in the update dialog. The skipped version is never
+ offered again; later ones still are.
+
### Fixed
- **The app no longer offers upstream YTSage's releases as its own updates.**
diff --git a/ytsage/core/ytsage_app_update.py b/ytsage/core/ytsage_app_update.py
new file mode 100644
index 0000000..de69664
--- /dev/null
+++ b/ytsage/core/ytsage_app_update.py
@@ -0,0 +1,201 @@
+"""
+Application update checks against SageTube's own releases
+=========================================================
+
+SageTube is a fork of oop7/YTSage. It keeps upstream's git history, its
+internal `ytsage` package name and its inherited tags -- but it is a different
+program with a different release stream, and this module is the line between
+them.
+
+**This checks SageTube. Nothing here touches upstream.** The three other
+updaters in this codebase legitimately track their own upstreams and are
+deliberately left alone:
+
+- yt-dlp binary -> gui/ytsage_gui_dialogs/ytsage_dialogs_update.py
+- Deno runtime -> core/ytsage_deno.py
+- ffmpeg -> utils/ytsage_constants.py
+
+They share only the word "update".
+
+Gitea's release API is shaped like GitHub's -- `tag_name`, `body`, `html_url`,
+`draft`, `prerelease` -- so the consuming code needs no special cases. The
+repository is anonymously readable, so no token is involved.
+
+Tag notes
+---------
+The repository carries upstream's tags up to `v5.3.0b` alongside SageTube's
+own. `packaging` reads a trailing `b` as a beta marker (`5.3.0b` -> `5.3.0b0`),
+so those inherited tags are correctly treated as pre-releases and are invisible
+to a stable instance. Parsing is still defensive: one unparseable tag must not
+take the whole check down.
+"""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional
+
+import requests
+from packaging.version import InvalidVersion, Version, parse as parse_version
+
+from .. import __version__
+from ..utils.ytsage_config_manager import ConfigManager
+from ..utils.ytsage_logger import logger
+
+API_BASE: str = "https://git.houmeres.sk/api/v1/repos/Houmeres/SageTube"
+RELEASES_PAGE: str = "https://git.houmeres.sk/Houmeres/SageTube/releases"
+
+DEFAULT_TIMEOUT: float = 8.0
+#: Don't ask the forge more than once a day, however often the app is started.
+CHECK_INTERVAL_SECONDS: int = 24 * 60 * 60
+#: How many releases to pull when the beta channel is on.
+BETA_PAGE_SIZE: int = 20
+
+
+@dataclass(frozen=True)
+class Release:
+ """One published release, normalised."""
+
+ version: Version
+ tag: str
+ url: str
+ body: str
+ prerelease: bool
+
+
+def _headers() -> Dict[str, str]:
+ return {
+ "Accept": "application/json",
+ "User-Agent": f"SageTube/{__version__}",
+ }
+
+
+def _to_release(payload: Dict[str, Any]) -> Optional[Release]:
+ """Normalise one API object, or None if it is unusable."""
+ tag = (payload.get("tag_name") or "").strip()
+ if not tag:
+ return None
+ try:
+ version = parse_version(tag.lstrip("vV"))
+ except (InvalidVersion, TypeError) as e:
+ # A hand-made or inherited tag that isn't a version. Skip it rather
+ # than letting it abort the whole check.
+ logger.debug(f"Ignoring unparseable release tag {tag!r}: {e}")
+ return None
+
+ return Release(
+ version=version,
+ tag=tag,
+ url=payload.get("html_url") or f"{RELEASES_PAGE}/tag/{tag}",
+ body=(payload.get("body") or "").strip(),
+ prerelease=bool(payload.get("prerelease")) or version.is_prerelease,
+ )
+
+
+def _get(path: str, timeout: float, **params: Any) -> Optional[Any]:
+ """GET a JSON endpoint. Returns None on any failure -- never raises."""
+ try:
+ response = requests.get(
+ f"{API_BASE}{path}",
+ headers=_headers(),
+ params=params or None,
+ timeout=timeout,
+ )
+ except requests.RequestException as e:
+ logger.debug(f"Update check request failed: {e}")
+ return None
+
+ if response.status_code == 404:
+ # No release matches (e.g. /releases/latest on a repo whose only
+ # releases are pre-releases). That is "nothing to offer", not an error.
+ logger.debug("Update check: no matching release published.")
+ return None
+ if response.status_code != 200:
+ logger.debug(f"Update check: API returned {response.status_code}")
+ return None
+
+ try:
+ return response.json()
+ except ValueError as e:
+ logger.debug(f"Update check: malformed JSON: {e}")
+ return None
+
+
+def fetch_latest(include_prerelease: bool = False, timeout: float = DEFAULT_TIMEOUT) -> Optional[Release]:
+ """
+ The newest published release, or None if there is nothing or the check failed.
+
+ With `include_prerelease` the whole release list is pulled and filtered
+ here rather than trusting server-side filters, because the highest
+ *version* is wanted -- not the most recently published, which is what the
+ API orders by.
+ """
+ if not include_prerelease:
+ payload = _get("/releases/latest", timeout)
+ if not isinstance(payload, dict):
+ return None
+ release = _to_release(payload)
+ # /releases/latest should never return a draft or pre-release, but a
+ # stable instance must not be handed one even if it does.
+ if release is None or release.prerelease:
+ return None
+ return release
+
+ payload = _get("/releases", timeout, limit=BETA_PAGE_SIZE, draft=False)
+ if not isinstance(payload, list):
+ return None
+
+ candidates: List[Release] = []
+ for item in payload:
+ if not isinstance(item, dict) or item.get("draft"):
+ continue
+ release = _to_release(item)
+ if release is not None:
+ candidates.append(release)
+
+ if not candidates:
+ return None
+ return max(candidates, key=lambda r: r.version)
+
+
+def is_newer(release: Release, current_version: str) -> bool:
+ """
+ Whether `release` should be offered over the running version.
+
+ Honours the version the user chose to skip, so "Skip this version" is not
+ silently undone by the next start.
+ """
+ try:
+ current = parse_version(str(current_version).lstrip("vV"))
+ except (InvalidVersion, TypeError):
+ logger.debug(f"Running version {current_version!r} is unparseable; skipping update check.")
+ return False
+
+ if release.version <= current:
+ return False
+
+ skipped = ConfigManager.get("skipped_update_version")
+ if skipped and str(skipped) == str(release.version):
+ logger.debug(f"Update {release.tag} available but skipped by the user.")
+ return False
+
+ return True
+
+
+def should_check(now: Optional[float] = None) -> bool:
+ """Rate-limit to CHECK_INTERVAL_SECONDS, so a restart is not a new check."""
+ if now is None:
+ now = time.time()
+ try:
+ last = float(ConfigManager.get("last_update_check") or 0)
+ except (TypeError, ValueError):
+ last = 0.0
+ # A clock moved backwards must not lock the check out until it catches up.
+ if last > now:
+ return True
+ return (now - last) >= CHECK_INTERVAL_SECONDS
+
+
+def mark_checked(now: Optional[float] = None) -> None:
+ ConfigManager.set("last_update_check", now if now is not None else time.time())
diff --git a/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py b/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
index b4a57a4..4df7e81 100644
--- a/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
+++ b/ytsage/gui/ytsage_gui_dialogs/ytsage_dialogs_base.py
@@ -203,7 +203,7 @@ class AboutDialog(QDialog):
# Title and Version - more compact
title_label = QLabel(
- "YTSage"
+ "SageTube"
)
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title_label)
diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py
index 98eae1b..b80ce2c 100644
--- a/ytsage/gui/ytsage_gui_main.py
+++ b/ytsage/gui/ytsage_gui_main.py
@@ -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...")
diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json
index 9e0e9df..628e987 100644
--- a/ytsage/languages/en.json
+++ b/ytsage/languages/en.json
@@ -305,8 +305,8 @@
"ytdlp_channel_switched": "✅ Successfully switched to {channel} channel!",
"ytdlp_channel_switch_failed": "❌ Failed to switch channel: {error}",
"ytdlp_current_channel": "Current channel: {channel}",
- "app_updates_title": "YTSage Updates",
- "check_app_updates": "Check for YTSage updates on startup",
+ "app_updates_title": "SageTube Updates",
+ "check_app_updates": "Check for SageTube updates on startup",
"check_beta_updates": "Receive Beta Updates",
"auto_update_title": "Auto-Update Settings",
"auto_update_header": "🔄 Auto-Update Settings",
@@ -466,12 +466,15 @@
},
"update_dialog": {
"title": "Update Available",
- "new_version_available": "A new version of YTSage is available!",
+ "new_version_available": "A new version of SageTube is available!",
"current_version_label": "Current version:",
"latest_version_label": "Latest version:",
"changelog": "Changelog",
"download_update": "Download Update",
- "remind_later": "Remind Me Later"
+ "remind_later": "Remind Me Later",
+ "skip_version": "Skip This Version",
+ "skip_version_tooltip": "Never offer {version} again. Later versions will still be shown.",
+ "changelog_unavailable": "No release notes were published for this version."
},
"playlist": {
"unknown": "Unknown Playlist",
diff --git a/ytsage/utils/ytsage_config_manager.py b/ytsage/utils/ytsage_config_manager.py
index 4f303ca..a363cb4 100644
--- a/ytsage/utils/ytsage_config_manager.py
+++ b/ytsage/utils/ytsage_config_manager.py
@@ -92,6 +92,7 @@ class ConfigManager:
"check_app_updates": False, # fork updates come from Gitea, not the upstream PyPI package
"check_beta_updates": False,
"last_update_check": 0,
+ "skipped_update_version": None, # set by the update dialog's "Skip this version"
"concurrent_fragments": 1,
"language": "en",
"ytdlp_channel": "stable",
diff --git a/ytsage/utils/ytsage_localization.py b/ytsage/utils/ytsage_localization.py
index a7eb4fa..9efba31 100644
--- a/ytsage/utils/ytsage_localization.py
+++ b/ytsage/utils/ytsage_localization.py
@@ -74,10 +74,18 @@ class LocalizationManager:
"generic_mode": "Generic Mode",
"enable_generic_mode": "Enable Generic Mode (support non-YouTube sites)",
"generic_mode_help": "Allows downloading from Dailymotion, CBC Gem, and other sites supported by yt-dlp.",
- "app_updates_title": "YTSage Updates",
- "check_app_updates": "Check for YTSage updates on startup",
+ "app_updates_title": "SageTube Updates",
+ "check_app_updates": "Check for SageTube updates on startup",
"check_beta_updates": "Receive Beta Updates"
},
+ # Only the keys added by SageTube live here; the rest of update_dialog
+ # is in every language file already. Without this a non-English UI
+ # would show the raw key for the buttons below.
+ "update_dialog": {
+ "skip_version": "Skip This Version",
+ "skip_version_tooltip": "Never offer {version} again. Later versions will still be shown.",
+ "changelog_unavailable": "No release notes were published for this version."
+ },
"tabs": {
"cookies": "Login with Cookies",
"custom_command": "Custom Command",