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",